|$ curl https://forge-ai.dev/api/markdown?path=docs/nextjs/state-management
$cat docs/state-management-in-next.js.md
updated Last weekยท35 min readยทpublished

State Management in Next.js

โ—†Next.jsโ—†Stateโ—†Reactโ—†Intermediate to Advanced๐ŸŽฏFree Tools
Introduction

State management in Next.js is not a single library decision โ€” it is an architectural decision about where state lives. The App Router makes the boundary between server and client explicit: Server Components can read cookies, databases, and caches during rendering, while Client Components own interactivity, browser events, and global UI state that does not belong in a URL or a database.

The first question is always: does this state need to survive a page refresh? If it lives in the database, it is server state. If it is derived from the URL, it is navigation state. If it is local to a form, dialog, or animation, it is client state. Each category has a preferred tool, and mixing them up is the root cause of most state bugs: over-fetching on the client, hydration mismatches, state leaks between users, and optimistic UI that never reconciles.

This guide covers the patterns that scale in production. We start with server state patterns, move through URL state and React Context, then evaluate Zustand, Redux Toolkit, and TanStack Query. We close with Server Actions, a decision matrix, and the pitfalls that break App Router applications. The goal is to pick the smallest tool that fits each problem rather than one global store for everything.

Server State Patterns

Server state is data that lives on a server, is fetched asynchronously, and is not owned by the browser. In Next.js, the cheapest place to read it is inside a Server Component, because the fetch happens during rendering, the result can be cached, and no client JavaScript is required to display it. The rule is simple: fetch on the server unless you have a clear reason to fetch on the client.

Server Components can pass data to Client Components through props. This is the most reliable pattern for hydration: the client receives the exact value that was rendered on the server, so there is no mismatch between the initial HTML and the first client render. The Client Component only needs to render the data, not fetch it.

server-props.tsx
TSX
1// app/page.tsx
2import { getProjects } from "@/lib/projects";
3import ProjectList from "@/components/ProjectList";
4
5export default async function HomePage() {
6 const projects = await getProjects();
7
8 return (
9 <main>
10 <h1>Projects</h1>
11 <ProjectList projects={projects} />
12 </main>
13 );
14}
15
16// components/ProjectList.tsx
17"use client";
18
19import { Project } from "@/lib/projects";
20
21export default function ProjectList({ projects }: { projects: Project[] }) {
22 return (
23 <ul>
24 {projects.map((project) => (
25 <li key={project.id}>{project.name}</li>
26 ))}
27 </ul>
28 );
29}

When data must be refreshed after an interaction, Server Actions are the server-side mutation primitive. An action can return a value, update a database, and then invalidate a cache or path. Because the action runs on the server, the returned value is guaranteed to be consistent with the canonical source. The client does not need to guess whether a mutation succeeded.

server-action-return.tsx
TSX
1// app/actions/project.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5import { createProject } from "@/lib/projects";
6
7export async function addProject(formData: FormData) {
8 const name = formData.get("name") as string;
9
10 const project = await createProject({ name });
11
12 revalidatePath("/");
13
14 return { id: project.id, name: project.name };
15}
16
17// app/page.tsx
18import { addProject } from "@/app/actions/project";
19
20export default async function HomePage() {
21 return (
22 <form action={addProject}>
23 <input name="name" placeholder="Project name" required />
24 <button type="submit">Add project</button>
25 </form>
26 );
27}

Caching is an integral part of server state. Next.js caches the result of fetch calls by default, and the React cache() function lets you deduplicate requests across a single render. If you call the same data function twice in a Server Component, wrap it in cache() so only one network request is made. This is especially important when data is read in layouts, pages, and nested components.

cache.tsx
TSX
1// lib/projects.ts
2import { cache } from "react";
3
4export const getProjects = cache(async () => {
5 const res = await fetch("https://api.example.com/projects", {
6 next: { revalidate: 60 },
7 });
8
9 if (!res.ok) throw new Error("Failed to fetch projects");
10
11 return res.json();
12});
โ„น

info

Keep server state out of global client stores. The client should receive server state as props or through a dedicated server-state library. This eliminates hydration mismatches and prevents one user's data from leaking into another user's session.
URL as State

The URL is the most underused state container in modern web apps. It is shareable, bookmarkable, survives refreshes, and integrates with the browser back button. In Next.js, useSearchParams and useRouter let you read and write query string state without importing a store library.

URL state is perfect for filters, pagination, modals, and search queries. When a user selects a category, updates the query string, or opens a detail view, the change should often be reflected in the URL. This keeps the UI reproducible and makes analytics straightforward. The server can also read the same search params during a Server Component render, enabling server-side filtering.

url-state.tsx
TSX
1// components/CategoryFilter.tsx
2"use client";
3
4import { useSearchParams, useRouter } from "next/navigation";
5
6const categories = ["all", "react", "nextjs", "css", "ai"];
7
8export default function CategoryFilter() {
9 const searchParams = useSearchParams();
10 const router = useRouter();
11 const selected = searchParams.get("category") || "all";
12
13 function handleChange(category: string) {
14 const params = new URLSearchParams(searchParams.toString());
15
16 if (category === "all") {
17 params.delete("category");
18 } else {
19 params.set("category", category);
20 }
21
22 router.replace(`/docs?${params.toString()}`, { scroll: false });
23 }
24
25 return (
26 <div className="flex gap-2">
27 {categories.map((category) => (
28 <button
29 key={category}
30 onClick={() => handleChange(category)}
31 className={selected === category ? "active" : ""}
32 >
33 {category}
34 </button>
35 ))}
36 </div>
37 );
38}

The scroll: false option prevents the page from jumping when the query string updates. Use router.replace for filter updates so the back button is not overwhelmed by every minor change, and router.push when the user takes a deliberate navigation action, such as moving to a new page.

server-url-state.tsx
TSX
1// app/page.tsx
2import { Suspense } from "react";
3import CategoryFilter from "@/components/CategoryFilter";
4import { getDocsByCategory } from "@/lib/docs";
5
6export default async function DocsPage({
7 searchParams,
8}: {
9 searchParams: Promise<{ category?: string }>;
10}) {
11 const { category } = await searchParams;
12 const docs = await getDocsByCategory(category);
13
14 return (
15 <main>
16 <Suspense fallback={<p>Loading filters...</p>}>
17 <CategoryFilter />
18 </Suspense>
19 <ul>
20 {docs.map((doc) => (
21 <li key={doc.slug}>{doc.title}</li>
22 ))}
23 </ul>
24 </main>
25 );
26}
๐Ÿ“

note

Always wrap components that call useSearchParams in Suspense. The hook relies on client-side search params during hydration, and the server cannot render it directly without a fallback boundary.
React Context

React Context is a first-class tool for dependency injection, not a global state manager. It excels at passing values down a subtree without prop drilling: themes, authentication session status, feature flags, and locale preferences. It does not perform well as a high-frequency state store, because every value change re-renders every consumer.

In the App Router, Context requires a Client Component. You cannot create or consume context in a Server Component. The usual pattern is to create a provider in a small client file, import it into a Server Component layout, and place it as low in the tree as possible. The children of the provider remain Server Components if they are not marked with "use client".

context-provider.tsx
TSX
1// components/ThemeProvider.tsx
2"use client";
3
4import { createContext, useContext, useState, ReactNode } from "react";
5
6type Theme = "dark" | "light";
7
8const ThemeContext = createContext<{
9 theme: Theme;
10 toggleTheme: () => void;
11} | null>(null);
12
13export default function ThemeProvider({ children }: { children: ReactNode }) {
14 const [theme, setTheme] = useState<Theme>("dark");
15
16 function toggleTheme() {
17 setTheme((prev) => (prev === "dark" ? "light" : "dark"));
18 }
19
20 return (
21 <ThemeContext.Provider value={{ theme, toggleTheme }}>
22 {children}
23 </ThemeContext.Provider>
24 );
25}
26
27export function useTheme() {
28 const context = useContext(ThemeContext);
29 if (!context) throw new Error("useTheme must be used inside ThemeProvider");
30 return context;
31}
32
33// app/layout.tsx
34import ThemeProvider from "@/components/ThemeProvider";
35
36export default function RootLayout({ children }: { children: React.ReactNode }) {
37 return (
38 <html lang="en">
39 <body>
40 <ThemeProvider>{children}</ThemeProvider>
41 </body>
42 </html>
43 );
44}
45
46// components/ThemeToggle.tsx
47"use client";
48
49import { useTheme } from "@/components/ThemeProvider";
50
51export default function ThemeToggle() {
52 const { theme, toggleTheme } = useTheme();
53
54 return <button onClick={toggleTheme}>Current theme: {theme}</button>;
55}

Context has well-known limitations. Split unrelated concerns into separate contexts so that changes in one do not cause re-renders in the other. Combine context with useMemo and useCallback to keep the value object stable. Without stability, every render of the provider creates a new context value, which triggers every consumer to re-render.

โš 

warning

Do not put server-fetched data into Context unless it is truly global and rarely changes. Passing server data as props keeps components decoupled and avoids hydration issues caused by the client overwriting the server-rendered HTML.
Zustand

Zustand is a small, unopinionated state management library that has become the default recommendation for App Router applications. It uses a plain hook-based store, requires no providers, and avoids the boilerplate of Redux. Its footprint is tiny, and it works entirely in client components.

A Zustand store is created with a single function. You define the initial state and actions, and components subscribe to slices of the state with selectors. This subscription model is more efficient than Context because selectors prevent re-renders when unrelated parts of the store change.

zustand-store.ts
TSX
1// stores/counter-store.ts
2import { create } from "zustand";
3
4interface CounterState {
5 count: number;
6 increment: () => void;
7 decrement: () => void;
8 reset: () => void;
9}
10
11export const useCounterStore = create<CounterState>((set) => ({
12 count: 0,
13 increment: () => set((state) => ({ count: state.count + 1 })),
14 decrement: () => set((state) => ({ count: state.count - 1 })),
15 reset: () => set({ count: 0 }),
16}));
zustand-counter.tsx
TSX
1// components/Counter.tsx
2"use client";
3
4import { useCounterStore } from "@/stores/counter-store";
5
6export default function Counter() {
7 const count = useCounterStore((state) => state.count);
8 const increment = useCounterStore((state) => state.increment);
9 const decrement = useCounterStore((state) => state.decrement);
10
11 return (
12 <div className="flex items-center gap-4">
13 <button onClick={decrement}>-</button>
14 <span>{count}</span>
15 <button onClick={increment}>+</button>
16 </div>
17 );
18}

Zustand also supports middleware for persistence, immer-based mutations, and devtools. The persist middleware is especially useful for storing UI preferences such as sidebar collapse state, theme mode, or draft form values. Persistence happens in a client component, so it does not affect server rendering.

zustand-persist.tsx
TSX
1// stores/ui-store.ts
2import { create } from "zustand";
3import { persist } from "zustand/middleware";
4
5interface UiState {
6 sidebarOpen: boolean;
7 toggleSidebar: () => void;
8}
9
10export const useUiStore = create<UiState>()(
11 persist(
12 (set) => ({
13 sidebarOpen: true,
14 toggleSidebar: () =>
15 set((state) => ({ sidebarOpen: !state.sidebarOpen })),
16 }),
17 {
18 name: "ui-preferences",
19 }
20 )
21);
โœ“

best practice

Keep Zustand stores close to the feature that owns them. One global UI store and several domain-specific stores is usually better than one monolithic store that every component depends on.
Redux Toolkit

Redux Toolkit is the right choice when state has complex cross-cutting logic, requires undo/redo, or benefits from a single immutable source of truth with strong TypeScript inference. It is heavier than Zustand, but the Redux DevTools ecosystem and the slice pattern make it maintainable in large teams.

In Next.js, Redux still lives in Client Components. The store must be created once per browser session, wrapped in a provider, and exposed through typed hooks. To avoid hydration issues, create the store in a client provider and initialize it from server-rendered props if necessary. Do not create the store during a Server Component render.

redux-slice.ts
TSX
1// stores/counter-slice.ts
2import { createSlice, PayloadAction } from "@reduxjs/toolkit";
3
4interface CounterState {
5 value: number;
6}
7
8const initialState: CounterState = { value: 0 };
9
10const counterSlice = createSlice({
11 name: "counter",
12 initialState,
13 reducers: {
14 increment: (state) => {
15 state.value += 1;
16 },
17 decrement: (state) => {
18 state.value -= 1;
19 },
20 setValue: (state, action: PayloadAction<number>) => {
21 state.value = action.payload;
22 },
23 },
24});
25
26export const { increment, decrement, setValue } = counterSlice.actions;
27export default counterSlice.reducer;
redux-store.ts
TSX
1// stores/store.ts
2import { configureStore } from "@reduxjs/toolkit";
3import counterReducer from "./counter-slice";
4
5export const makeStore = () =>
6 configureStore({
7 reducer: {
8 counter: counterReducer,
9 },
10 });
11
12export type AppStore = ReturnType<typeof makeStore>;
13export type RootState = ReturnType<AppStore["getState"]>;
14export type AppDispatch = AppStore["dispatch"];
redux-hooks.ts
TSX
1// stores/hooks.ts
2import { useDispatch, useSelector, TypedUseSelectorHook } from "react-redux";
3import type { RootState, AppDispatch } from "./store";
4
5export const useAppDispatch = () => useDispatch<AppDispatch>();
6export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
redux-provider.tsx
TSX
1// components/StoreProvider.tsx
2"use client";
3
4import { useRef, ReactNode } from "react";
5import { Provider } from "react-redux";
6import { makeStore, AppStore } from "@/stores/store";
7
8export default function StoreProvider({ children }: { children: ReactNode }) {
9 const storeRef = useRef<AppStore | null>(null);
10
11 if (!storeRef.current) {
12 storeRef.current = makeStore();
13 }
14
15 return <Provider store={storeRef.current}>{children}</Provider>;
16}
17
18// components/Counter.tsx
19"use client";
20
21import { useAppSelector, useAppDispatch } from "@/stores/hooks";
22import { increment, decrement } from "@/stores/counter-slice";
23
24export default function Counter() {
25 const count = useAppSelector((state) => state.counter.value);
26 const dispatch = useAppDispatch();
27
28 return (
29 <div className="flex items-center gap-4">
30 <button onClick={() => dispatch(decrement())}>-</button>
31 <span>{count}</span>
32 <button onClick={() => dispatch(increment())}>+</button>
33 </div>
34 );
35}

Redux Toolkit also supports RTK Query for server-state caching. While RTK Query is powerful, TanStack Query is more common in Next.js projects. Choose Redux for client state that justifies the boilerplate, and use a dedicated server-state library for server data.

๐Ÿ“

note

Make the store creation lazy inside the provider. Creating the store at module level in a Client Component can cause state to leak between server renders, tests, or if the component is imported during server rendering.
TanStack Query (React Query)

TanStack Query is the leading library for managing server state in React. It handles caching, background refetching, deduplication, mutations, and optimistic updates. It does not replace a global client state store; it owns the asynchronous boundary between the client and the server.

In Next.js, TanStack Query works best in Client Components that need to fetch after hydration, or when the user needs to interactively refetch data. The library also supports prefetching in Server Components and hydrating the query cache so the client does not refetch the same data immediately after hydration.

query-provider.tsx
TSX
1// app/providers.tsx
2"use client";
3
4import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5import { ReactNode, useState } from "react";
6
7export default function QueryProvider({ children }: { children: ReactNode }) {
8 const [queryClient] = useState(
9 () =>
10 new QueryClient({
11 defaultOptions: {
12 queries: {
13 staleTime: 60 * 1000,
14 refetchOnWindowFocus: false,
15 },
16 },
17 })
18 );
19
20 return (
21 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
22 );
23}
query-client.tsx
TSX
1// components/Projects.tsx
2"use client";
3
4import { useQuery } from "@tanstack/react-query";
5
6async function fetchProjects() {
7 const res = await fetch("/api/projects");
8 if (!res.ok) throw new Error("Failed to fetch projects");
9 return res.json();
10}
11
12export default function Projects() {
13 const { data, isLoading, error } = useQuery({
14 queryKey: ["projects"],
15 queryFn: fetchProjects,
16 });
17
18 if (isLoading) return <p>Loading projects...</p>;
19 if (error) return <p>Error: {error.message}</p>;
20
21 return (
22 <ul>
23 {data.map((project: { id: string; name: string }) => (
24 <li key={project.id}>{project.name}</li>
25 ))}
26 </ul>
27 );
28}

Prefetching from a Server Component lets you render the initial HTML with the data and dehydrate the query cache into the client. The client provider then rehydrates the cache, so the first render uses the server data without a network request. The query key is the source of truth for invalidation and cache identity.

query-prefetch.tsx
TSX
1// app/page.tsx
2import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
3import { getQueryClient } from "@/lib/query-client";
4import { fetchProjects } from "@/lib/projects";
5import Projects from "@/components/Projects";
6
7export default async function HomePage() {
8 const queryClient = getQueryClient();
9
10 await queryClient.prefetchQuery({
11 queryKey: ["projects"],
12 queryFn: fetchProjects,
13 });
14
15 return (
16 <HydrationBoundary state={dehydrate(queryClient)}>
17 <Projects />
18 </HydrationBoundary>
19 );
20}

Mutations use useMutation. After a successful mutation, invalidate the relevant query keys to refresh the UI. The cache is your single source of truth, so mutations do not need to manually update local state unless you are applying optimistic updates.

query-mutation.tsx
TSX
1// components/CreateProject.tsx
2"use client";
3
4import { useMutation, useQueryClient } from "@tanstack/react-query";
5
6async function createProject(name: string) {
7 const res = await fetch("/api/projects", {
8 method: "POST",
9 body: JSON.stringify({ name }),
10 headers: { "Content-Type": "application/json" },
11 });
12
13 if (!res.ok) throw new Error("Failed to create project");
14 return res.json();
15}
16
17export default function CreateProject() {
18 const queryClient = useQueryClient();
19
20 const mutation = useMutation({
21 mutationFn: createProject,
22 onSuccess: () => {
23 queryClient.invalidateQueries({ queryKey: ["projects"] });
24 },
25 });
26
27 return (
28 <form
29 onSubmit={(e) => {
30 e.preventDefault();
31 const formData = new FormData(e.currentTarget);
32 const name = formData.get("name") as string;
33 mutation.mutate(name);
34 }}
35 >
36 <input name="name" required />
37 <button type="submit" disabled={mutation.isPending}>
38 {mutation.isPending ? "Creating..." : "Create project"}
39 </button>
40 </form>
41 );
42}
โ„น

info

Use TanStack Query for read-heavy server state that refetches in the browser. Use Server Actions when Next.js is your only data layer and you want to avoid hand-written API routes.
Server Actions for Mutations

Server Actions are Next.js's answer to mutations. They run on the server, can be bound directly to forms, and integrate with the cache through revalidatePath and revalidateTag. Because they are functions, not HTTP routes, they reduce the surface area of your API and keep mutation logic next to the components that use it.

When a mutation takes time, wrap it in useTransition. This keeps the UI responsive while the action runs. The transition state lets you show pending UI, and the action result updates the UI once the server responds. For optimistic updates, combine useOptimistic with the transition so the UI responds immediately and then reconciles with the server result.

todo-action.ts
TSX
1// app/actions/todo.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5import { createTodo } from "@/lib/todos";
6
7export async function addTodo(formData: FormData) {
8 const title = formData.get("title") as string;
9
10 await createTodo(title);
11
12 revalidatePath("/todos");
13}
todo-form.tsx
TSX
1// components/TodoForm.tsx
2"use client";
3
4import { useRef } from "react";
5import { useTransition } from "react";
6import { addTodo } from "@/app/actions/todo";
7
8export default function TodoForm() {
9 const [isPending, startTransition] = useTransition();
10 const formRef = useRef<HTMLFormElement>(null);
11
12 return (
13 <form
14 ref={formRef}
15 action={async (formData) => {
16 startTransition(async () => {
17 await addTodo(formData);
18 formRef.current?.reset();
19 });
20 }}
21 >
22 <input name="title" placeholder="New todo" required />
23 <button type="submit" disabled={isPending}>
24 {isPending ? "Adding..." : "Add todo"}
25 </button>
26 </form>
27 );
28}

Optimistic UI is where the client renders the expected result before the server confirms it. React's useOptimistic hook is designed for this pattern. You call it with the current state and an update function, then dispatch an optimistic update while the transition is pending. The hook automatically rolls back to the server state if the action fails.

optimistic-todos.tsx
TSX
1// components/TodoList.tsx
2"use client";
3
4import { useOptimistic, useTransition } from "react";
5import { addTodo } from "@/app/actions/todo";
6
7type Todo = { id: string; title: string; completed: boolean };
8
9export default function TodoList({ todos }: { todos: Todo[] }) {
10 const [isPending, startTransition] = useTransition();
11 const [optimisticTodos, setOptimisticTodos] = useOptimistic<Todo[], string>(
12 todos,
13 (state, title) => [
14 ...state,
15 { id: crypto.randomUUID(), title, completed: false },
16 ]
17 );
18
19 function handleSubmit(formData: FormData) {
20 const title = formData.get("title") as string;
21
22 setOptimisticTodos(title);
23
24 startTransition(async () => {
25 await addTodo(formData);
26 });
27 }
28
29 return (
30 <div>
31 <form action={handleSubmit}>
32 <input name="title" required />
33 <button type="submit" disabled={isPending}>Add</button>
34 </form>
35 <ul>
36 {optimisticTodos.map((todo) => (
37 <li key={todo.id}>{todo.title}</li>
38 ))}
39 </ul>
40 </div>
41 );
42}
โœ•

danger

Optimistic UI should only be used for mutations that are likely to succeed. Always provide a clear error state and a way to reconcile the UI when the server rejects or partially applies the mutation.
Choosing the Right Tool

The right state tool is the one that matches the lifetime and ownership of the data. Server state belongs near the server. Navigation state belongs in the URL. Local UI state belongs in the component, or in a small client store if many components share it. Global state with complex rules belongs in a dedicated store.

ToolBest forPersists?Server / ClientComplexity
Server propsRead-only data rendered once per requestRefreshServerLow
URL stateFilters, pagination, modals, searchURL / refreshBothLow
React ContextDependency injection, themes, session flagsNoClientLow
ZustandLocal client state, UI preferences, simple storesOptionalClientLow-Medium
Redux ToolkitComplex state, undo/redo, cross-cutting logicOptionalClientMedium-High
TanStack QueryServer state, caching, refetching, mutationsCacheClient (+prefetch)Medium
Server ActionsMutations without API routesDatabaseServer invoked from clientLow-Medium

Start with the simplest option. If the data comes from the server, fetch it in a Server Component. If the user needs to filter it, put the filter in the URL. If multiple components need to toggle the same UI state, reach for Zustand. Only introduce Redux or TanStack Query when the complexity of the problem justifies the cost of the library and its associated mental model.

๐Ÿ”ฅ

pro tip

Avoid the temptation to normalize all state into one global store. Mixed server and client state in a single store is the most common cause of over-fetching, stale data, and hydration mismatches in Next.js apps.
Common Pitfalls

1. Putting server state in a global client store. The store will hold stale data, refetch too often, and leak state between users if the store is created on the server. Use server props, TanStack Query, or server actions instead.

2. Over-fetching in Client Components. Every useEffect fetch adds a waterfall and delays interactivity. Prefer Server Components for the initial data load and pass results as props.

3. Hydration mismatches from client-only state. If a value differs between server and client โ€” such as a date format or persisted theme โ€” gate it behind an effect or use a suppressible mismatch strategy.

4. Creating stores during server render. Client stores must be created once per browser session. Creating them at module level or during server rendering can share state across requests and users.

5. State leaks between requests. Any mutable state held outside a request scope is dangerous in a server environment. Keep server state in the database, request cache, or React cache().

6. Forgetting to invalidate caches. After a server action or mutation, stale caches will serve old data until they are revalidated. Always call revalidatePath, revalidateTag, or invalidate the relevant TanStack Query key.

7. Using Context for high-frequency updates. Context is not optimized for rapid state changes. A counter that updates every frame will re-render every consumer. Use Zustand, Redux, or local state instead.

8. Missing loading and error states. Async state without explicit UI boundaries creates janky experiences. Use Suspense, error boundaries, and the loading states provided by your data library.

9. Over-optimizing UI before measuring. Not every form needs optimistic updates. Start with useTransition and add useOptimistic only when the user experience justifies the complexity.

10. Mixing state ownership. If the URL owns a filter value, do not also store it in a global store. Choose a single source of truth for each piece of state and update it consistently.

โœ“

best practice

When in doubt, keep state as low in the tree and as close to the server as possible. The fewer layers a value passes through, the easier it is to reason about, test, and cache.
$Blueprint โ€” Engineering DocumentationยทSection ID: NEXT-STATEยทRevision: 1.0