js

Using Zustand with Remix for Safe Client State Management

Learn how to use Zustand with Remix to manage client-only state safely, avoid hydration mismatches, and build faster UX patterns.

Using Zustand with Remix for Safe Client State Management

Lately, I’ve been thinking a lot about a quiet tension in modern web development. We have powerful frameworks like Remix that bring data loading and mutations closer to the server, making our apps faster and more resilient. Yet, when I’m building, I still bump into those classic client-side puzzles: a modal that needs to open, a theme preference to remember, or the items sitting in a shopping cart before checkout. Remix handles the data from my database with grace, but what about the data living purely in the user’s session? This gap is why I’ve been exploring how to pair Remix with a nimble state manager, and that’s where Zustand has become my go-to tool.

Remix rethinks how we build for the web. It leans into the fundamentals—HTTP, forms, and server-side rendering. You define loader functions to get data and action functions to handle mutations. The framework then seamlessly threads that data into your components. For state that belongs to the server or the URL, this model is exceptional. But what about the state that doesn’t? Think of a sidebar’s open/close status, a complex filter panel for a product list, or the current step in a sign-up wizard. This state is ephemeral, purely about the user’s immediate interaction. Shipping this back to the server for every change is heavy and slow.

So, we need a place to keep this “client-only” state. But here’s an interesting problem: Remix apps are server-rendered. The HTML you see first is generated on the server. If we just slap a typical React state manager into the mix, we risk a “hydration mismatch.” That’s when the server-rendered HTML doesn’t match what React expects to find when it takes over on the client, leading to errors or a flickery experience.

This is precisely where Zustand fits so well. It’s a tiny library that creates a store. You don’t need to wrap your app in a provider. You just create a store with a function, and your components can use it. Its simplicity is its strength, especially in a Remix context where we want to add client state without adding complexity or a large bundle.

How do we make them work together safely? The key is in how we initialize the Zustand store. We must ensure it starts with the same data on the client that was used on the server. For state that is completely independent, like a UI toggle, we can just create it. But what if our client state needs a starting point from the server?

Let’s say we have a user profile edit form. The server loads the user’s data, but we want a draft of their edits to live on the client until they hit save.

// app/models/user.server.ts
export async function getUserById(id: string) {
  return db.user.findUnique({ where: { id } });
}

First, we get the data in our Remix loader.

// app/routes/profile.edit.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { getUserById } from "~/models/user.server";

export const loader = async ({ params }) => {
  const user = await getUserById(params.userId);
  return json({ user });
};

Now, we create a Zustand store that can be initialized with this server data. We use a pattern with a create function.

// app/stores/useProfileStore.ts
import { create } from 'zustand';

interface ProfileState {
  draft: Partial<User>;
  updateDraft: (field: keyof User, value: string) => void;
  initialize: (serverData: User) => void;
}

export const createProfileStore = (initData: User) =>
  create<ProfileState>((set) => ({
    draft: initData,
    updateDraft: (field, value) =>
      set((state) => ({
        draft: { ...state.draft, [field]: value },
      })),
    initialize: (serverData) => set({ draft: serverData }),
  }));

// This is a React hook we'll use in our component.
// On first client-side render, it creates the store with the server data.
export const useProfileStore = (initData: User) => {
  const storeRef = React.useRef<ReturnType<typeof createProfileStore>>();
  if (!storeRef.current) {
    storeRef.current = createProfileStore(initData);
  }
  return storeRef.current();
};

In our Remix route component, we use the loader data to initialize this store.

// app/routes/profile.edit.tsx
export default function ProfileEdit() {
  const { user } = useLoaderData<typeof loader>();

  // The store is created once with the server user data
  const { draft, updateDraft } = useProfileStore(user);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    updateDraft(e.target.name as keyof User, e.target.value);
  };

  // The form submits to a Remix action, sending the `draft` state
  return (
    <Form method="post">
      <input name="fullName" value={draft.fullName} onChange={handleChange} />
      <button type="submit">Save Changes</button>
    </Form>
  );
}

See what happened? The server provided the initial user data. Our store used it as a starting point. All edits happen on the client, providing instant feedback. When the user submits, the final draft object is sent back to a Remix action on the server. The client state and server state work in separate layers but can talk when needed.

What about a more global state, like a shopping cart? You might ask, “Can’t I just use cookies or a database session?” You absolutely can for persistence. But for the immediate UI experience—the animation when an item is added, the live total update—a client store is perfect. You can have a Zustand store for the cart that holds items. When an item is added, you update the store and post to a Remix action to sync with your server session. This gives you that snappy feel while keeping the true source of data on the server.

The beauty of this setup is separation. Remix owns the data from your resources—the products, the user profile, the blog posts. Zustand owns the state of the interface—what’s open, what’s selected, what’s in a temporary draft. Each tool does what it’s best at. This keeps your Remix loaders and actions focused on data integrity and your client bundle light and fast.

Have you ever struggled with prop drilling a simple isOpen state through three layers of components? Zustand solves that with minimal fuss. Need to persist a UI preference to localStorage? You can add that to your store creation with a few lines. The combination feels respectful of the Remix philosophy: use the platform for data, and use a minimal, focused tool for the things the platform doesn’t cover.

I encourage you to try this pattern. Start with a small piece of UI state in a new store. Feel how it coexists with your server-rendered page. I think you’ll find it clears up a lot of “where should this state live?” questions. It has certainly streamlined my own projects.

What kind of client state have you found trickiest to manage in your Remix apps? Have you tried other libraries, or does this Zustand approach spark a new idea for you? I’d love to hear about your experiences in the comments below. If this way of thinking about state layers was helpful, please share it with other developers who might be wrestling with the same concepts


As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!


📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!


Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Keywords: Remix, Zustand, client state management, hydration mismatch, React state



Similar Posts
Blog Image
Complete Guide to Next.js and Prisma Integration for Type-Safe Full-Stack Development

Learn how to integrate Next.js with Prisma ORM for type-safe full-stack TypeScript apps. Build scalable web applications with seamless database operations.

Blog Image
Build Full-Stack Apps Fast: Complete Svelte + Supabase Integration Guide for Modern Web Development

Learn how to integrate Svelte with Supabase for powerful full-stack web development. Build reactive UIs with PostgreSQL backend, authentication & real-time features.

Blog Image
How Fastify and Typesense Supercharged My Product Search Performance

Discover how combining Fastify and Typesense created a blazing-fast, scalable search experience for large product catalogs.

Blog Image
Complete Event Sourcing System with Node.js TypeScript and EventStore: Professional Tutorial with Code Examples

Learn to build a complete event sourcing system with Node.js, TypeScript & EventStore. Master domain events, projections, concurrency handling & REST APIs for scalable applications.

Blog Image
Building Event-Driven Microservices with NestJS: Complete Guide to RabbitMQ, MongoDB, and Saga Patterns

Learn to build scalable event-driven microservices with NestJS, RabbitMQ & MongoDB. Master Saga patterns, error handling & deployment strategies.

Blog Image
Why Deno, Oak, and MongoDB Might Be the Future of Backend Development

Explore how Deno, Oak, and MongoDB combine to create a secure, modern, and minimal backend stack for building APIs.