Where Should State Live? Lifting State Up, Controlled Inputs, Derived State and useReducer
State should live in the closest common ancestor of the components that need it; computable values stay out of state, you store an identity instead of a copy, and state that must reset on a prop change gets a key. We lift Bookstore's cart into App, turn it into a type-safe reducer with useReducer and a discriminated union, and show with real output that a separately kept total goes wrong on remove and that a details panel without a key carries a note to another book.
Version note: The examples in this post were built and run with React 19.3, TypeScript 7.0, Vite 8.3 and Node.js 24; the reducer was also run directly with Node.js 24's type stripping, and browser behaviour was observed with Playwright 1.65 in headless Chromium.
Predict First
In the React catalog of Bookstore, the online bookstore used as the shared example across this site's series, clicking a book's title opens a details panel below the list. The panel has an input called "Gift note"; the note is the panel's own state:
export function BookDetailsPanel({ book }: BookDetailsPanelProps) {
// Belongs to one book; the parent resets it with key={book.isbn}
const [giftNote, setGiftNote] = useState('');
The user opens Dune's details and types "For Grace" into the note, then clicks Emma's title. The panel is the same component; only the book prop changed. What happens to the note in Emma's panel: empty, or still "For Grace"? Is there a difference between rendering the panel as <BookDetailsPanel book={selectedBook} /> and as <BookDetailsPanel key={selectedBook.isbn} book={selectedBook} />? In Angular, is a component inside an @if recreated when its input changes? The answer is in the key section.
The question of this post is where state should live. The catalog has grown: a search box, sorting, a details panel, a cart summary in the header and a cart panel on the side. Which state belongs in which component, which values should never be state at all, and when should the cart logic move from useState to useReducer?
Lifting State Up: Who Should Own the Cart?
Three places use the cart: the summary in the header, the "In cart" count on the cards and the cart panel on the side. The closest common component that draws all of them is App. In React, when two components must see the same data, the data moves to their closest common ancestor and comes down as props, while changes go up through callbacks. This is called "lifting state up":
import { useReducer } from 'react';
import { CartPanel } from '@/features/cart/CartPanel';
import { cartItemCount, cartReducer, cartTotal } from '@/features/cart/cartReducer';
import { CartSummary } from '@/features/cart/CartSummary';
import { CatalogPage } from '@/features/catalog/CatalogPage';
import type { BookSummary } from '@/features/catalog/types';
import { Layout } from '@/shared/Layout';
export default function App() {
// Lifted state: the header, the catalog and the cart panel all need the cart
const [lines, dispatch] = useReducer(cartReducer, []);
const quantities = Object.fromEntries(lines.map((line) => [line.isbn, line.quantity]));
function handleAdd(book: BookSummary, amount: number) {
dispatch({ type: 'added', book, amount });
}
return (
<Layout title="Bookstore" headerExtra={<CartSummary itemCount={cartItemCount(lines)} total={cartTotal(lines)} />}>
<div className="page">
<div>
<CatalogPage quantities={quantities} onAdd={handleAdd} />
</div>
<CartPanel lines={lines} dispatch={dispatch} />
</div>
</Layout>
);
}
Angular has two ways to meet the same need: the parent holds a signal, passes it to the child with input() and the child reports back with output(); or the data goes into a service with a single instance at the root and everyone injects it. In React the counterpart of the second way is Context and external stores (such as Zustand); we see them in later posts. The first way is exactly what we do here. The rule is to put state in the closest common ancestor of the components that need it, and no higher. The search text, the sort and the selected book matter only to the catalog, so they went into CatalogPage, not App:
import { useState } from 'react';
import { BookDetailsPanel } from '@/features/catalog/BookDetailsPanel';
import { BookList } from '@/features/catalog/BookList';
import { sampleBooks } from '@/features/catalog/sample-books';
import { SearchBox } from '@/features/catalog/SearchBox';
import type { BookSummary } from '@/features/catalog/types';
type SortBy = 'title' | 'price';
type CatalogPageProps = {
quantities: Record<string, number>;
onAdd: (book: BookSummary, amount: number) => void;
};
export function CatalogPage({ quantities, onAdd }: CatalogPageProps) {
// State that only the catalog needs stays here, not in App
const [query, setQuery] = useState('');
const [sortBy, setSortBy] = useState<SortBy>('title');
// Store the id, not a copy of the book object
const [selectedIsbn, setSelectedIsbn] = useState<string | null>(null);
const term = query.trim().toLowerCase();
const books = sampleBooks
.filter((b) => b.title.toLowerCase().includes(term) || b.author.toLowerCase().includes(term))
.toSorted((a, b) => (sortBy === 'price' ? a.price - b.price : a.title.localeCompare(b.title)));
const selectedBook = sampleBooks.find((b) => b.isbn === selectedIsbn) ?? null;
return (
<>
<h1>Catalog</h1>
<div className="toolbar">
<SearchBox value={query} onChange={setQuery} />
<label>
Sort by{' '}
<select value={sortBy} onChange={(e) => setSortBy(e.target.value as SortBy)}>
<option value="title">Title</option>
<option value="price">Price</option>
</select>
</label>
</div>
<p className="muted">{books.length} of {sampleBooks.length} books</p>
<BookList books={books} quantities={quantities} onAdd={onAdd} onSelect={setSelectedIsbn} />
{selectedBook && <BookDetailsPanel key={selectedBook.isbn} book={selectedBook} />}
</>
);
}
This split has a concrete benefit: while the user types into the search box, only CatalogPage and what is below it render; the header and the cart panel are not affected. Had we put the search text in App, every keystroke would render the whole page.
Controlled Inputs: Who Owns the Text?
The search box is a controlled input: it does not hold its value, it receives it from the parent and reports every change to the parent.
type SearchBoxProps = {
value: string;
onChange: (value: string) => void;
};
// Controlled input: the parent owns the text, the input only shows it and reports changes
export function SearchBox({ value, onChange }: SearchBoxProps) {
return (
<input type="search" placeholder="Search by title or author" aria-label="Search books"
value={value} onChange={(e) => onChange(e.target.value)} />
);
}
The flow: the user types a letter, onChange passes the new text to setQuery, CatalogPage renders again and the new value comes down to the input. The text on the screen is the state itself at every moment. This is the spelled-out version of the [value] + (input) pair, or of ngModel, in Angular. There is no two-way binding; data flows down in one direction and changes flow up in the other.
To see what happens when a link in the chain is missing, we removed onChange and typed "Dune" into the input in development mode:
error: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.
input value after typing Dune: ""
React both warns and locks the input: value comes from state, state does not change, so every keystroke is undone. An uncontrolled input is also a valid choice: you give a starting value with defaultValue, the DOM holds the value and you read it when the form is submitted. Fields that affect something else on every keystroke, like the note field in the details panel, are controlled; large forms often prefer uncontrolled inputs and a form library. We compare them in the forms post.
Do Not Put Derived Values into State
In CatalogPage, the filtered and sorted list, the "1 of 5 books" text and the selected book are not state. All three are computed on every render from query, sortBy and selectedIsbn. On the cart side, the total and the item count are computed with cartTotal(lines) and cartItemCount(lines). react.dev's guide to state structure sums this up as "avoid redundant state": if information can be calculated during render from props or existing state, it should not be a separate piece of state.
To see the price of breaking the rule, we wrote an App that puts the total into its own state:
// Lifted state: the header, the catalog and the cart panel all need the cart
const [lines, dispatch] = useReducer(cartReducer, []);
// WRONG: a copy of something we can compute; only handleAdd keeps it in sync
const [total, setTotal] = useState(0);
const quantities = Object.fromEntries(lines.map((line) => [line.isbn, line.quantity]));
function handleAdd(book: BookSummary, amount: number) {
dispatch({ type: 'added', book, amount });
setTotal((t) => t + book.price * amount);
}
We added Dune and Emma and removed Dune from the cart panel:
add Dune, Emma : Cart: 2 item(s) · ₺738.90 | panel Total: ₺738.90
remove Dune : Cart: 1 item(s) · ₺738.90 | panel Total: ₺249.90
The panel shows the correct total (249.90) while the header still says 738.90. handleAdd took on the job of keeping the total up to date; the remove and quantity-change paths know nothing about it. Every new action makes that list longer. A computed value has no such synchronization job. If the calculation is expensive (a filter over thousands of rows), useMemo can keep the result, but that is still a cache, not state; we measure when it is needed in the performance post.
State Structure: Store the Identity, Not a Copy
Writing useState<BookSummary | null> for the selected book is the first idea that comes to mind. We stored selectedIsbn and looked the book up in the list. The reason is react.dev's "avoid duplication" principle: if a copy of the book object goes into state, then when the list is updated (the price changed, the API returned new data) the panel keeps showing the old copy. Storing the identity keeps the data in one place.
The guide's other principles find counterparts in Bookstore too. Group values that always change together: the cart lines are one array, each line its own object. Avoid contradicting state: instead of two separate booleans isLoading and isError, use a single status field; we do that for the search results in the sixth post. Prefer a flat structure over deeply nested state: normalize trees such as books inside categories and reviews inside books.
Resetting State with key
The details panel:
import { useState } from 'react';
import type { BookSummary } from '@/features/catalog/types';
import { formatPrice } from '@/shared/format';
type BookDetailsPanelProps = {
book: BookSummary;
};
export function BookDetailsPanel({ book }: BookDetailsPanelProps) {
// Belongs to one book; the parent resets it with key={book.isbn}
const [giftNote, setGiftNote] = useState('');
return (
<section className="details" aria-label="Book details">
<h2>{book.title}</h2>
<p>{book.author}, {book.publishedYear} · {book.category} · {formatPrice(book.price)}</p>
<label>
Gift note{' '}
<input value={giftNote} onChange={(e) => setGiftNote(e.target.value)} maxLength={120} />
</label>
<p className="muted">{120 - giftNote.length} characters left</p>
</section>
);
}
We ran the scenario from "Predict First" in two ways. Without key:
Dune details : Dune | note = For Grace | 111 characters left
switch to Emma : Emma | note = For Grace | 111 characters left
With key={selectedBook.isbn} (last two lines):
search "aus" : 1 of 5 books | Emma
add 2 Dune, 1 Emma : Cart: 3 item(s) · ₺1,227.90 | Total: ₺1,227.90
Dune quantity -> 0 : Cart: 1 item(s) · ₺249.90 | Emma ₺249.90 Remove
Dune details : Dune | note = For Grace | 111 characters left
switch to Emma : Emma | note = | 120 characters left
React keeps a component's state according to its position in the tree. Without key, switching from Dune to Emma rendered the same component type (BookDetailsPanel) in the same place; React read it as "same panel, new props" and kept the note state. Emma's panel opened with the note written for Grace. When we give a key, React treats a different key as a different component: it removes the old panel and creates the new one with fresh state.
Angular behaves differently: when the input of a component inside @if (selectedBook(); as book) changes, Angular does not recreate the component, it only updates the input; the component's own fields are kept. To reset them you either clean up by hand with ngOnChanges/effect or tie the state to the input with linkedSignal from Angular 19. In React the counterpart is not an effect but a key. Writing an effect for "reset state when a prop changes" is one of the common mistakes we look at in the next post.
The Cart with useReducer
The ways the cart can change have multiplied: add, change quantity, remove, clear. Instead of spreading this logic across components' handlers, we collect it in a single reducer function:
import type { CartLine } from '@/features/cart/types';
import type { BookSummary } from '@/features/catalog/types';
// Every way the cart can change, as a discriminated union on "type"
export type CartAction =
| { type: 'added'; book: BookSummary; amount: number }
| { type: 'quantityChanged'; isbn: string; quantity: number }
| { type: 'removed'; isbn: string }
| { type: 'cleared' };
// A pure function: same (state, action) always gives the same new state; no mutation, no I/O
export function cartReducer(lines: CartLine[], action: CartAction): CartLine[] {
switch (action.type) {
case 'added': {
const { book, amount } = action;
return lines.some((line) => line.isbn === book.isbn)
? lines.map((line) => (line.isbn === book.isbn ? { ...line, quantity: line.quantity + amount } : line))
: [...lines, { isbn: book.isbn, title: book.title, unitPrice: book.price, quantity: amount }];
}
case 'quantityChanged':
return action.quantity <= 0
? lines.filter((line) => line.isbn !== action.isbn)
: lines.map((line) => (line.isbn === action.isbn ? { ...line, quantity: action.quantity } : line));
case 'removed':
return lines.filter((line) => line.isbn !== action.isbn);
case 'cleared':
return [];
default: {
// If a new action type is added and not handled above, this line stops compiling
const unhandled: never = action;
throw new Error(`Unknown cart action: ${JSON.stringify(unhandled)}`);
}
}
}
export function cartItemCount(lines: CartLine[]): number {
return lines.reduce((sum, line) => sum + line.quantity, 0);
}
export function cartTotal(lines: CartLine[]): number {
return lines.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0);
}
A reducer takes two things: the current state and an action describing what happened. It returns the new state. In TypeScript the actions are a discriminated union on the type field; switch narrows the action's type in every branch, so you can reach action.book in the 'added' branch but not in 'removed'. The foundation of this technique is in "Union and Literal Types in TypeScript: One Value, Several Possibilities" in the TypeScript series. The action names are in the past tense (added, removed): they describe "what happened", not "what to do".
useReducer(cartReducer, []) returns a [state, dispatch] pair. Components no longer know how to update the cart; they only report what happened. The cart panel receives dispatch directly:
import type { Dispatch } from 'react';
import { cartTotal, type CartAction } from '@/features/cart/cartReducer';
import type { CartLine } from '@/features/cart/types';
import { formatPrice } from '@/shared/format';
type CartPanelProps = {
lines: CartLine[];
dispatch: Dispatch<CartAction>;
};
export function CartPanel({ lines, dispatch }: CartPanelProps) {
if (lines.length === 0) {
return <aside className="cart-panel muted">Your cart is empty.</aside>;
}
return (
<aside className="cart-panel">
<h2>Cart</h2>
<ul>
{lines.map((line) => (
<li key={line.isbn}>
{line.title}{' '}
<input type="number" min={0} max={99} aria-label={`Quantity of ${line.title}`} value={line.quantity}
onChange={(e) => dispatch({ type: 'quantityChanged', isbn: line.isbn, quantity: e.target.valueAsNumber || 0 })} />{' '}
{formatPrice(line.unitPrice * line.quantity)}{' '}
<button type="button" onClick={() => dispatch({ type: 'removed', isbn: line.isbn })}>Remove</button>
</li>
))}
</ul>
<p className="total">Total: {formatPrice(cartTotal(lines))}</p>
<button type="button" onClick={() => dispatch({ type: 'cleared' })}>Clear cart</button>
</aside>
);
}
We see two benefits of type safety in the compiler output. Misspelling an action name:
src/App.tsx(15,16): error TS2322: Type '"add"' is not assignable to type '"added" | "cleared" | "quantityChanged" | "removed"'.
Adding a new action to the union and forgetting to handle it in the reducer (thanks to the never assignment in the default branch):
src/features/cart/cartReducer.ts(31,13): error TS2322: Type '{ type: "couponApplied"; code: string; }' is not assignable to type 'never'.
The biggest gain from a reducer is testability. It does not depend on React, the DOM or the browser; it is a plain function. Since Node.js 24 can run TypeScript files directly by stripping their types, we tried the reducer without setting up any test infrastructure:
import { cartItemCount, cartReducer, cartTotal, type CartAction } from './cartReducer.ts';
const dune = { isbn: '9780441172719', title: 'Dune', author: 'Frank Herbert', category: 'Science Fiction', price: 489, publishedYear: 1965 };
const emma = { isbn: '9780141439587', title: 'Emma', author: 'Jane Austen', category: 'Classics', price: 249.9, publishedYear: 1815 };
const actions: CartAction[] = [
{ type: 'added', book: dune, amount: 1 },
{ type: 'added', book: emma, amount: 2 },
{ type: 'added', book: dune, amount: 1 },
{ type: 'quantityChanged', isbn: emma.isbn, quantity: 0 },
];
let lines = cartReducer([], actions[0]);
for (const action of actions.slice(1)) {
const next = cartReducer(lines, action);
console.log(action.type.padEnd(16), JSON.stringify(next.map((l) => `${l.title} × ${l.quantity}`)), 'same array?', next === lines);
lines = next;
}
console.log('items:', cartItemCount(lines), 'total:', cartTotal(lines));
// Reducer is replayable: the same actions give the same state again
console.log('replay equal:', JSON.stringify(actions.reduce(cartReducer, [])) === JSON.stringify(lines));
added ["Dune × 1","Emma × 2"] same array? false
added ["Dune × 2","Emma × 2"] same array? false
quantityChanged ["Dune × 2"] same array? false
items: 2 total: 978
replay equal: true
Every action returned a new array (same array? false), Emma's line was removed when its quantity went to 0, and replaying the same list of actions from scratch with reduce gave the same result. We write proper unit tests with Vitest in the testing post; the reducer will be the easiest part to test there.
When to use useReducer? For simple state that holds a single value (the card's quantity picker, a panel that is open or closed), useState is enough. When several paths change the same data, when one update touches several fields, or when you want to take the logic out of the component and test it, a reducer is better.
The Link to Flux and Container/Presentational
This structure is not a new idea. Actions flowing in one direction through a dispatcher and only the reducer changing state is the essence of the Flux architecture; "Flux and Unidirectional Data Flow: Why Didn't Two-Way Binding Scale?" in the UI Architecture Patterns series covered its history and reasoning. NgRx reducers come from the same idea. App and CatalogPage are containers that hold data, while BookCard, SearchBox and CartSummary are presentational components that only take props and call callbacks; the split from "Component Architecture: Container and Presentational Components, Composition and Contracts" in the same series. We use this split not as a strict rule but as a way to answer "which component owns this state?".
In Production
Lifting state up works well in small trees, but carrying the cart through props to a button ten levels down (prop drilling) becomes tedious. That is where Context or a store comes in; the topics of the seventh and eleventh posts. Put state in the closest common ancestor first, and move to the next tool only when the problem really shows up.
A reducer has no side effects: an HTTP request, writing to localStorage, a call to Date.now() or crypto.randomUUID() does not belong in it. StrictMode calls reducers twice in development too; a reducer with a side effect gives itself away immediately. If you need an identity or a time, the code that dispatches the action should produce the value and put it into the action.
Bookstore's real cart lives in the API. Then this reducer's place will change: the cart that comes from the server becomes server state and lives in TanStack Query's cache, while the reducer stays for state that belongs to the browser, such as the steps of the checkout form.
Common Pitfalls
Moving everything into App: putting the search text, sort and selection at the very top "because we might need them some day" renders the whole tree on every keystroke. Put state in the lowest common ancestor that needs it.
Copying props into state: const [title, setTitle] = useState(book.title) takes only the value from the first render; when book changes, title is not updated. Use the prop directly, and give a key if you need a reset.
Keeping a derived value in state: the header and the panel above showing different totals. The total, the count and the filtered list are computed.
Mutating state in a reducer: lines.push(...) and return lines brings the late cart bug from the previous post into the reducer. A reducer must always return a new value too.
A Small Experiment
★☆☆ Add a "Clear" button next to the search box. Decide whether the button belongs inside SearchBox or in CatalogPage, and explain your choice in terms of who owns the state.
★★☆ Add a { type: 'quantityIncreased'; isbn: string } action to the reducer and put a "+" button on every line of the cart panel. See where the compiler stops you, then add a line for this action to reducer-demo.ts.
★★★ Without using key on the details panel, build a structure that keeps the note per book: with a state of type Record<string, string>, each book's note stays separate and is not lost when moving between books. Where should this state live?
All the state so far was born in the browser and stayed in the browser; even the book list comes from a fixed file. In the next post we fetch the list from the Bookstore API: the real purpose of useEffect, cleanup, the dependency array, the double run in StrictMode, race conditions and the "you might not need an effect" principle.