State Management in Remix: Patterns and Libraries
Remix flips the traditional single-page application model on its head by embracing the web platform. Instead of treating the server as an afterthought, Remix makes server-side data loading and mutations first-class citizens. This shift fundamentally changes how we think about state management. In this tutorial, we'll explore what state management looks like in Remix, why it matters, the built-in patterns you should lean on, and the libraries that complement them.
What Is State Management in Remix?
State management refers to how an application stores, updates, and shares data across its UI. In a typical React SPA, you might reach for Redux, Zustand, or the Context API to coordinate data between components. In Remix, much of that coordination happens on the server through loaders and actions, with the browser acting as a thin, declarative client.
The key insight is that Remix encourages you to push state to the URL and the server whenever possible. Local component state still has its place, but global application state is often better expressed as route data. This means fewer client-side stores, less duplication between server and client, and a more predictable mental model.
Why It Matters
- Predictability: When state lives in the URL or is derived from loaders, refreshing the page or sharing a link always restores the correct UI.
- Performance: Server-rendered data avoids waterfall fetches on the client and reduces the amount of JavaScript shipped.
- Simplicity: You often don't need a separate state library at all, which shrinks your bundle and your bug surface.
- Resilience: Because Remix revalidates data after mutations, your UI stays in sync with the server without manual cache invalidation.
The Built-in State Model
Remix provides several primitives that cover most state needs. Before reaching for a third-party library, you should understand these layers.
1. URL State
The URL is the most durable form of state. Search params, route params, and path segments all describe the current view. Remix gives you useSearchParams and useParams to read them, and navigation APIs to update them.
import { useSearchParams, useNavigate } from "@remix-run/react";
export default function ProductList() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const page = Number(searchParams.get("page") ?? "1");
function goToPage(next: number) {
const params = new URLSearchParams(searchParams);
params.set("page", String(next));
navigate(`?${params.toString()}`);
}
return (
<div>
<p>Current page: {page}</p>
<button onClick={() => goToPage(page + 1)}>Next</button>
</div>
);
}
Because the page number lives in the URL, a user can bookmark it, share it, or refresh without losing their place.
2. Server State via Loaders and Actions
Loaders fetch data on the server before the route renders. Actions handle mutations. After an action runs, Remix automatically revalidates the matching loaders, keeping the UI in sync.
import { json, type LoaderFunction, type ActionFunction } from "@remix-run/node";
import { useLoaderData, Form } from "@remix-run/react";
import { db } from "~/db.server";
export const loader: LoaderFunction = async () => {
const todos = await db.todo.findMany({ orderBy: { createdAt: "desc" } });
return json({ todos });
};
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const title = String(formData.get("title"));
await db.todo.create({ data: { title } });
return json({ ok: true });
};
export default function Todos() {
const { todos } = useLoaderData<typeof loader>();
return (
<div>
<Form method="post">
<input name="title" placeholder="New todo" />
<button type="submit">Add</button>
</Form>
<ul>
{todos.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
</div>
);
}
Notice there is no client-side store. The list is server state, and Remix handles revalidation after the form submission automatically.
3. Optimistic UI with useNavigation
For snappy interactions, you can read the pending navigation state to show optimistic feedback while an action is in flight.
import { useNavigation, Form } from "@remix-run/react";
export default function CommentBox() {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<Form method="post">
<textarea name="body" />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Posting..." : "Post comment"}
</button>
</Form>
);
}
4. Local Component State
For ephemeral UI concerns like toggling a dropdown or tracking a hovered element, plain useState is still the right tool. Not everything belongs in the URL or on the server.
import { useState } from "react";
export default function Dropdown() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen((v) => !v)}>Menu</button>
{open && <div className="menu">...</div>}
</div>
);
}
5. Shared Client State with useRouteLoaderData
When a nested route needs data from a parent loader, you can use useRouteLoaderData instead of prop drilling or a context provider.
import { useRouteLoaderData } from "@remix-run/react";
export default function ProfileBadge() {
const root = useRouteLoaderData("root");
if (!root?.user) return null;
return <span>{root.user.name}</span>;
}
When to Reach for a Library
The built-in model covers most cases, but there are scenarios where a dedicated library is justified:
- Complex client-side forms with cross-field validation and derived values.
- Real-time data that updates outside of navigation, such as WebSockets or server-sent events.
- Optimistic updates that are too intricate for
useNavigation. - Offline-first features requiring a local cache.
Popular Libraries That Complement Remix
Zustand for Lightweight Client Stores
Zustand is a small, hook-based store that works well for genuinely client-only state, like a theme preference or a multi-step wizard that doesn't need to survive a refresh.
import { create } from "zustand";
interface CartState {
items: string[];
add: (id: string) => void;
clear: () => void;
}
export const useCart = create<CartState>((set) => ({
items: [],
add: (id) => set((s) => ({ items: [...s.items, id] })),
clear: () => set({ items: [] }),
}));
// Usage in a component
function CartButton() {
const items = useCart((s) => s.items);
const add = useCart((s) => s.add);
return <button onClick={() => add("sku-123")}>Cart ({items.length})</button>;
}
Keep in mind that Zustand state resets on navigation unless you persist it. If the data matters across refreshes, consider moving it to the URL or a loader instead.
React Hook Form for Complex Forms
For forms with dynamic fields, conditional validation, or large datasets, React Hook Form pairs nicely with Remix actions. You can collect the form state on the client and submit it as a standard form post.
import { useForm } from "react-hook-form";
import { Form, useActionData } from "@remix-run/react";
interface FormData {
email: string;
password: string;
}
export default function Signup() {
const actionData = useActionData();
const { register, handleSubmit, formState: { errors } } = useForm<FormData>();
return (
<Form method="post" onSubmit={handleSubmit(() => {})}>
<input {...register("email", { required: true })} />
{errors.email && <span>Email is required</span>}
<input type="password" {...register("password", { required: true, minLength: 8 })} />
{errors.password && <span>Min 8 characters</span>}
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit">Sign up</button>
</Form>
);
}
TanStack Query for Client-Side Caching
If you have data that updates in real time or comes from a third-party API you don't want to proxy through loaders, TanStack Query can manage that cache on the client. The trick is to avoid duplicating data that loaders already provide.
import { useQuery } from "@tanstack/react-query";
export default function LiveStockPrice({ symbol }: { symbol: string }) {
const { data, isLoading } = useQuery({
queryKey: ["price", symbol],
queryFn: async () => {
const res = await fetch(`/api/price/${symbol}`);
return res.json();
},
refetchInterval: 5000,
});
if (isLoading) return <p>Loading...</p>;
return <p>{symbol}: ${data.price}</p>;
}
Optimistic UI with useOptimistic
React 19's useOptimistic hook works well inside Remix components to show a provisional state while an action is pending.
import { useOptimistic, useRef } from "react";
import { Form, useLoaderData } from "@remix-run/react";
export default function TodoList() {
const { todos } = useLoaderData<typeof loader>();
const formRef = useRef(null);
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newTitle: string) => [
{ id: "temp", title: newTitle, pending: true },
...state,
]
);
return (
<>
<Form
method="post"
ref={formRef}
onSubmit={(e) => {
const formData = new FormData(e.currentTarget);
addOptimistic(String(formData.get("title")));
}}
>
<input name="title" />
<button type="submit">Add</button>
</Form>
<ul>
{optimisticTodos.map((t) => (
<li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>
{t.title}
</li>
))}
</ul>
</>
);
}
Best Practices
- Prefer the URL. If a piece of state affects what the user sees or could be shared, put it in search params.
- Prefer loaders and actions. Most application data belongs on the server. Let Remix handle revalidation.
- Keep client stores small. Use Zustand or Context only for truly ephemeral, client-only concerns.
- Avoid duplicating server data on the client. If a loader already provides it, don't cache it again in a client store.
- Use optimistic UI sparingly. It improves perceived performance but adds complexity. Reach for it when latency is noticeable.
- Colocate related state. Route-level loaders keep data near the components that use it, which improves maintainability.
- Validate on the server. Even if you validate on the client, always validate in your action. The client is a convenience, not a security boundary.
Putting It All Together
A typical Remix app uses a layered approach: URL state for navigation and filters, loaders and actions for the bulk of application data, useNavigation or useOptimistic for pending feedback, useState for ephemeral UI, and a library like Zustand or React Hook Form only when the built-in tools aren't enough. This layering keeps your codebase lean and your data flow predictable.
Conclusion
State management in Remix is less about choosing the right library and more about choosing the right location for your state. By leaning on the URL, loaders, and actions, you offload most of the hard work to the server and the browser, which are remarkably good at it. Client-side libraries still have a place, but they should fill specific gaps rather than replace Remix's built-in model. When you embrace this philosophy, your applications become simpler to reason about, easier to test, and more resilient by default. Start with the primitives Remix gives you, add a library only when you feel real pain, and your state management strategy will scale naturally with your app.