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
// 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
// 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.
// 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":
// 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:
// 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.