This episode covers advanced patterns and state management: client-side state with React hooks, shared state and server-driven UI, progressive enhancement with optimistic updates, and long-lived data with background revalidation.

You already have an application that works and is fast. Now it's time to make it feel alive: state that interacts smoothly, UI that responds instantly, and data that stays fresh without manual effort. This is the world of advanced patterns and state in Remix.
Remix's philosophy places state with discipline: the server is the source of truth, and the client renders what the server sends. Most of the tricky state problems in SPAs simply disappear — you don't need fragile two-way synchronization, because Remix revalidates data automatically.
Episode 15 covers local state with hooks, shared state and server-driven UI, optimistic updates, and long-lived data revalidation.
For purely local interactions — toggles, tabs, dropdowns — plain React state is enough. useState, useReducer, and useCallback keep logic close to its component. Local state is best for ephemeral UI that doesn't need the server.
import { useReducer } from "react";
function reducer(state, aksi) {
switch (aksi.tipe) {
case "tambah": return { jumlah: state.jumlah + 1 };
case "kurang": return { jumlah: state.jumlah - 1 };
default: return state;
}
}
export default function Penghitung() {
const [state, dispatch] = useReducer(reducer, { jumlah: 0 });
return (
<div>
<p>Jumlah: {state.jumlah}</p>
<button onClick={() => dispatch({ tipe: "tambah" })}>Tambah</button>
</div>
);
}useReducer holds state logic that's starting to get complex in a single pure function. For counters and similar cases, it's easier to test than many useState calls.
A simple rule: if state is used by only one component, keep it in that component. As soon as two components need the same data, consider lifting it to the parent. If the data comes from the server, don't duplicate it — let the loader own it.
Data used by many routes is best loaded in the parent route. With nested routes, the parent renders the Outlet and its children — useLoaderData in the parent gives all children access to the same data without duplication. This is Remix's built-in form of shared state.
This philosophy reverses direction: it's not the client that requests data and then decides the UI, but the server that sends data and the client that renders according to it. A loader that returns data and a component that renders based on that data is called server-driven UI. The result: consistent display rules that are easy to test.
export async function loader() {
return { status: "pending", pesan: "Menunggu moderasi" };
}
export default function StatusKonten() {
const data = useLoaderData();
if (data.status === "pending") {
return <p>Konten menunggu peninjauan.</p>;
}
return <p>Konten aktif.</p>;
}An optimistic update shows the result as if it had already succeeded, before the server confirms. In Remix, useFetcher enables this: show the new state immediately, submit it to the action, then let revalidation confirm the real result.
import { useFetcher } from "@remix-run/react";
export default function TombolSuka({ postId, sudahSuka }) {
const fetcher = useFetcher();
const suka = fetcher.formData
? fetcher.formData.get("suka") === "ya"
: sudahSuka;
return (
<fetcher.Form method="post">
<input type="hidden" name="postId" value={postId} />
<input type="hidden" name="suka" value={suka ? "tidak" : "ya"} />
<button type="submit">{suka ? "Batal suka" : "Suka"}</button>
</fetcher.Form>
);
}fetcher.formData contains the form data currently being submitted — the basis for the optimistic view. When revalidation finishes, the official state replaces the temporary view.
Optimistic updates are safe for actions that almost certainly succeed and are easy to undo: likes, reads, status updates. For payments or permanent deletes, wait for the server's confirmation — mistakes there are far more expensive.
Remix revalidates loaders automatically after an action finishes. This closes the data loop: whatever an action changes, the UI immediately shows the latest result. You don't write manual refetch code; Remix decides when data needs to be refreshed.
For data that changes from outside — notifications, prices, job status — use intervals or events. Manual revalidation can be triggered with useRevalidator from Remix. useRevalidator gives you control to call the loaders again without navigating.
For true real-time needs — chat or collaboration — consider WebSocket or a separate push service. Remix covers most cases, but data that must appear within seconds has its own dedicated solutions.
Episode 15 completes the state puzzle: local state with hooks for interactions, shared state via parent routes and server-driven UI, optimistic updates with useFetcher, and automatic plus manual revalidation for long-lived data. State is no longer scary.
The key takeaways:
In the next episode, episode 16, we'll discuss testing and quality — unit testing React components, integration testing for loaders and actions, E2E testing with Playwright or Cypress, and accessibility testing and quality audits. State is alive; now let's make sure nothing breaks.