โ† Back to DevBytes

State Management in Remix: Patterns and Libraries

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

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:

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

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.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles