Skip to content

Next.js

A todo app on the App Router, demonstrating the two things SSR changes about using a store: where the instance lives, and when persist is allowed to touch localStorage. Full source in examples/todo-redux-style-nextjs.

The store

ts
// lib/store.ts
import { withPlugins } from "@kin-store/core";
import { immer, persist } from "@kin-store/plugins";

export type Filter = "all" | "active" | "done";
export type Todo = { id: number; text: string; done: boolean };
export type TodoState = { items: Todo[]; filter: Filter };

// Factory so each client render gets its own store instance — no shared
// state between SSR requests. Provided to the component tree via
// StoreProvider.
export function createTodoStore() {
  return withPlugins<TodoState>({ items: [], filter: "all" })
    .use(
      "persist",
      persist({
        key: "nextjs-todo",
        // Skip auto-hydration: localStorage is not available during SSR.
        // Providers.tsx calls store.persist.hydrate() after the client mounts.
        skipHydration: true,
        // Persist only items; filter resets to "all" on every page load.
        selector: (s) => ({ items: s.items }),
      }),
    )
    .use(
      immer({
        reducers: {
          addTodo(draft, text: string) {
            draft.items.push({ id: Date.now(), text, done: false });
          },
          toggleTodo(draft, id: number) {
            const item = draft.items.find((it) => it.id === id);
            if (item) item.done = !item.done;
          },
          removeTodo(draft, id: number) {
            draft.items = draft.items.filter((it) => it.id !== id);
          },
          clearDone(draft) {
            draft.items = draft.items.filter((it) => !it.done);
          },
          setFilter(draft, filter: Filter) {
            draft.filter = filter;
          },
        },
      }),
    );
}

export type TodoStore = ReturnType<typeof createTodoStore>;

It's a factory function, not a module-level singleton. A module-level store would be shared across every SSR request handled by the same server process — one user's todos leaking into another's response. createTodoStore() gives each render its own instance instead.

persist is configured with skipHydration: true because localStorage doesn't exist on the server. Hydration is triggered explicitly on the client instead (below). selector also excludes filter from persistence, so a returning visitor always lands back on the "all" view rather than whatever filter they last had open.

Wiring it up

tsx
// app/Providers.tsx
"use client";

import { useEffect, useState } from "react";
import { StoreProvider } from "@kin-store/react";
import { createTodoStore } from "@/lib/store.ts";

export function Providers({ children }: { children: React.ReactNode }) {
  const [store] = useState(createTodoStore);

  useEffect(() => {
    // Hydrate after mount so we never read localStorage on the server.
    store.persist.hydrate();
  }, [store]);

  return <StoreProvider store={store}>{children}</StoreProvider>;
}

useState(createTodoStore) runs the factory once per component instance (not once per render), and StoreProvider makes that instance available to the whole tree via context instead of a module import, which is what lets each request get its own store in the first place.

tsx
// app/layout.tsx
import type { Metadata } from "next";
import { Providers } from "./Providers.tsx";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

page.tsx itself stays a Server Component — only the leaf components that actually read or write the store need "use client":

tsx
// app/page.tsx
import { TodoFilter } from "./components/TodoFilter.tsx";
import { TodoInput } from "./components/TodoInput.tsx";
import { TodoList } from "./components/TodoList.tsx";

export default function Page() {
  return (
    <div>
      <TodoInput />
      <TodoFilter />
      <TodoList />
    </div>
  );
}

Reading and writing from a component

Client components pull the store out of context with useStoreContext, then use useStore/useSelector and dispatch as usual:

tsx
// app/components/TodoList.tsx
"use client";

import { useStore, useStoreContext } from "@kin-store/react";
import type { Todo, TodoStore } from "@/lib/store.ts";

function TodoItem({ item }: { item: Todo }) {
  const store = useStoreContext<TodoStore>();

  return (
    <li>
      <input
        type="checkbox"
        checked={item.done}
        onChange={() => store.dispatch.toggleTodo(item.id)}
      />
      <span>{item.text}</span>
      <button onClick={() => store.dispatch.removeTodo(item.id)}>×</button>
    </li>
  );
}

export function TodoList() {
  const store = useStoreContext<TodoStore>();
  const { items, filter } = useStore(store);

  const visible = items.filter(
    (it) => filter === "all" || (filter === "active" ? !it.done : it.done),
  );

  return (
    <ul>
      {visible.map((item) => <TodoItem key={item.id} item={item} />)}
    </ul>
  );
}

useStoreContext<TodoStore>() is what makes the store type-safe here, since there's no module-level export to import a type from directly. See StoreProvider and useStoreContext for the general pattern.

MIT License. Comparison