Lesson 17 · Summary
Summary: the principles of React
The 15 principles of the whole course, explained with "not like this / like this" examples.
Loading lesson…
Lesson 17 · Summary
The 15 principles of the whole course, explained with "not like this / like this" examples.
Loading lesson…
Throughout the course you've been accompanied by images from ordinary life: a cook with no memory, a notebook, a bank account, a school PA system, a hotel with a front desk. They had a single purpose – to make you recall, for every topic, why it works the way it does. When you write your own app, you won't have a lesson at hand, but you will remember these images.
This lesson brings nothing new. It takes everything you've seen in the course and puts it together into fifteen principles from which you can derive almost any decision when writing React. For each principle you'll find an analogy, an explanation of why it holds, a "not like this / like this" example and a link to the lesson where the topic is covered in detail.
All of React rests on one equation: UI = f(state). A component is a function that computes from props and state what the screen should look like. It doesn't draw or modify anything itself – it returns a description (JSX) and React makes sure the DOM matches it.
Three rules follow from this:
// ❌ Impure render: it changes data outside itself and the result depends on how many times it was calledlet renderCount = 0function Guest({ name }: { name: string }) {renderCount++return <li>#{renderCount} {name}</li>}// ✅ Pure render: everything it needs comes from outsidefunction Guest({ name, order }: { name: string; order: number }) {return <li>#{order} {name}</li>}
// ❌ Imperatively: "after the click hide the button, show a spinner, then show the message…"// ✅ Declaratively: describe each state separately, React handles the transitionsfunction Submit({ status }: { status: 'idle' | 'sending' | 'sent' }) {if (status === 'sending') return <Spinner />if (status === 'sent') return <p>Sent ✔</p>return <button type="submit">Send</button>}
📚 In depth in the lesson A quick refresher of the basics.
Only what changes over time and can't be computed from anything else belongs in state. Everything else is a prop, a constant, or a value computed during render. Every piece of information should have exactly one place where it lives – as soon as you have it twice, sooner or later the copies drift apart.
// ❌ Duplicated state + an effect that "catches up"const [todos, setTodos] = useState<Todo[]>([])const [filter, setFilter] = useState<'all' | 'done'>('all')const [visible, setVisible] = useState<Todo[]>([])const [doneCount, setDoneCount] = useState(0)useEffect(() => {setVisible(filter === 'all' ? todos : todos.filter((t) => t.done))setDoneCount(todos.filter((t) => t.done).length)}, [todos, filter])// → an extra render, stale values for a moment, more places for bugs// ✅ Only the necessary state, compute the restconst [todos, setTodos] = useState<Todo[]>([])const [filter, setFilter] = useState<'all' | 'done'>('all')const visible = filter === 'all' ? todos : todos.filter((t) => t.done)const doneCount = todos.filter((t) => t.done).length
The same principle applies to selection: don't store a copy of the selected object, store its ID.
// ❌ A copy: after the item is renamed, the detail still shows the old nameconst [selected, setSelected] = useState<Item | null>(null)// ✅ ID + lookup during render: always up-to-date dataconst [selectedId, setSelectedId] = useState<string | null>(null)const selected = items.find((i) => i.id === selectedId) ?? null
| Question | If yes… |
|---|---|
| Does it come from the parent? | It’s a prop, not state. |
| Does it never change? | It’s a constant (feel free to put it outside the component). |
| Can it be computed from other state/props? | Compute it during render (useMemo if it’s expensive). |
| Is it a copy of something already in state? | Store just the ID or key. |
| Does it change and must the UI react to it? | Only now is it state. |
📚 In depth in the lesson State and events.
A state value is frozen within a single render. setCount(count + 1) doesn't change count right away – it schedules a new render in which count will be new. All handlers and effects from a given render keep seeing its snapshot.
function Counter() {const [count, setCount] = useState(0)const addThree = () => {// ❌ All three lines read the same snapshot (0) → the result is 1setCount(count + 1)setCount(count + 1)setCount(count + 1)}const addThreeOk = () => {// ✅ An updater always receives the latest value → the result is 3setCount((c) => c + 1)setCount((c) => c + 1)setCount((c) => c + 1)}}
React detects a change by comparing references (Object.is). If you mutate an object or array, the reference stays the same and React redraws nothing. That's why state is never changed, only replaced with a new value.
// ❌ Mutation – same reference, React doesn't see the changetodos.push(newTodo); setTodos(todos)user.name = 'Eve'; setUser(user)// ✅ New valuessetTodos((prev) => [...prev, newTodo]) // addsetTodos((prev) => prev.filter((t) => t.id !== id)) // removesetTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))) // updatesetUser((prev) => ({ ...prev, name: 'Eve' })) // objectsetItems((prev) => prev.toSorted(byName)) // sorting without mutation
📚 In depth in the lesson State and events.
The most common architectural mistake in React is putting state in the wrong place – typically everything into one global store, or server data into useState. It helps to tell what kind of state you have and choose the tool accordingly. As a general rule: keep state as low as you can and lift it only when more components need it.
| Kind of state | Example | Where it goes |
|---|---|---|
| Local UI state | an open menu, text in an input, hover | useState |
| Shared between siblings | the selected item in a list + the detail | lift it to the nearest common parent |
| Complex, with many transitions | a 4-step wizard, an editor | useReducer |
| Rarely changing, needed deep down | theme, signed-in user, language | Context |
| Should survive a reload / be shareable by link | filters, page, sorting, open tab | URL (search params, route params) |
| Server data | a product list, a profile | TanStack Query (cache) |
| App-wide client state, changing often | a cart, a document in progress | Zustand / Redux Toolkit |
// Lifting state up: two siblings need the same value → it moves to the parentfunction Inbox() {const [selectedId, setSelectedId] = useState<string | null>(null)return (<><MessageList selectedId={selectedId} onSelect={setSelectedId} /><MessageDetail id={selectedId} /></>)}
📚 In depth in the lesson Application state management.
Code in a component has three possible places: render (computing the UI), a handler (reacting to a specific user action) and an effect (synchronising with something outside React). Ask: Why should this code run?
// ❌ An effect as a "reaction to an event" – indirect, fragile, also runs on other changesconst [submitted, setSubmitted] = useState(false)useEffect(() => {if (submitted) {post('/api/order', cart)showToast('Ordered!')}}, [submitted])// ✅ The logic belongs where the event happenedasync function handleOrder() {await post('/api/order', cart)showToast('Ordered!')}
When you do write an effect, it must be able to start and stop: whatever the effect opens, the cleanup closes. Strict Mode runs effects twice on purpose (connect → disconnect → connect) to expose a missing cleanup.
// ✅ Synchronisation with an external system + cleanup + handling a race conditionuseEffect(() => {const controller = new AbortController()fetch(`/api/users/${userId}`, { signal: controller.signal }).then((r) => r.json()).then(setUser).catch((e) => { if (e.name !== 'AbortError') setError(e) })// Does userId change before the response arrives? We cancel the old request,// so a slow old response can't overwrite newer data.return () => controller.abort()}, [userId]) // every reactive value the effect reads
📚 In depth in the lesson Effects (useEffect).
useRef remembers a value between renders, but changing it doesn't trigger a render. That makes it good for things the user doesn't see: a DOM element, a timer ID, a previous value, a library instance. Whatever shows up in the UI belongs in state.
function Stopwatch() {const [elapsed, setElapsed] = useState(0) // ✅ the user sees it → stateconst intervalRef = useRef<number | null>(null) // ✅ just a technical detail → refconst start = () => {intervalRef.current = window.setInterval(() => setElapsed((e) => e + 1), 1000)}const stop = () => {if (intervalRef.current !== null) clearInterval(intervalRef.current)}// …}// ❌ A ref instead of state: the value changes, but the screen stays staleconst countRef = useRef(0)<button onClick={() => countRef.current++}>{countRef.current}</button>
Work with the DOM through a ref only where it can't be done declaratively: focus, scroll, measuring, video playback. In React 19, ref is an ordinary prop, so you no longer need forwardRef.
// React 19: ref as a regular propfunction TextInput({ ref, ...props }: React.ComponentProps<'input'>) {return <input ref={ref} {...props} />}const inputRef = useRef<HTMLInputElement>(null)<TextInput ref={inputRef} /><button onClick={() => inputRef.current?.focus()}>Edit</button>
📚 In depth in the lesson Refs (useRef).
When several handlers change state in different ways, the logic gets spread across the whole component. A reducer puts it in one place: components only report events (card_moved), and the reducer decides how they turn into new state. It's a pure function, so you can test it without any UI and easily add, say, undo.
type Action =| { type: 'item_added'; name: string }| { type: 'item_toggled'; id: string }| { type: 'cleared_done' }function reducer(state: Item[], action: Action): Item[] {switch (action.type) {case 'item_added':return [...state, { id: crypto.randomUUID(), name: action.name, done: false }]case 'item_toggled':return state.map((i) => (i.id === action.id ? { ...i, done: !i.done } : i))case 'cleared_done':return state.filter((i) => !i.done)}}const [items, dispatch] = useReducer(reducer, [])dispatch({ type: 'item_toggled', id }) // ✅ "what happened"// instead of setItems(items.map(...)) scattered across five handlers
| useState | useReducer |
|---|---|
| Simple, independent values | Several values that change together |
| A few ways the state changes | Many different transitions |
| The logic is trivial | You want to test or share the logic (e.g. with Context) |
📚 In depth in the lesson Complex state (useReducer).
Context holds no state itself – it only delivers a value deep into the tree without prop drilling. The state still lives in useState/useReducer inside the Provider. Every change of the value re-renders all consumers, so it suits data that changes rarely.
// ✅ The pattern: Provider + a custom hook with a checkconst ThemeContext = createContext<Theme | null>(null)export function ThemeProvider({ children }: { children: React.ReactNode }) {const [theme, setTheme] = useState<'light' | 'dark'>('light')const value = useMemo(() => ({ theme, setTheme }), [theme]) // stable referencereturn <ThemeContext value={value}>{children}</ThemeContext>}export function useTheme() {const ctx = use(ThemeContext)if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>')return ctx}
📚 In depth in the lesson Context.
A custom hook is an ordinary function starting with use that calls other hooks. Every component that calls it gets its own independent copy of the state. A hook isn't for sharing data (that's Context or a store), but for reusing behaviour and naming intent.
function useDebouncedValue<T>(value: T, delay = 300) {const [debounced, setDebounced] = useState(value)useEffect(() => {const id = setTimeout(() => setDebounced(value), delay)return () => clearTimeout(id)}, [value, delay])return debounced}// The component reads like a sentence – what it does, not howfunction Search() {const [query, setQuery] = useState('')const debouncedQuery = useDebouncedValue(query)const { data } = useQuery({ queryKey: ['search', debouncedQuery], queryFn: () => search(debouncedQuery) })// …}
📚 In depth in the lesson Custom hooks.
Not every field has to be controlled. When you only need the value on submit, leave it in the DOM and read it from FormData. A controlled input (value + onChange) makes sense when you have to react while typing – live validation, formatting, dependent fields.
React 19 Actions add a pending state, errors and optimistic UI on top of that, without hand-written isLoading variables.
async function subscribe(prev: State, formData: FormData): Promise<State> {const email = String(formData.get('email'))if (!email.includes('@')) return { error: 'Invalid e-mail' }await api.subscribe(email)return { error: null, done: true }}function Newsletter() {const [state, formAction, isPending] = useActionState(subscribe, { error: null })return (<form action={formAction}><input name="email" defaultValue="" /> {/* uncontrolled – that's enough */}<button disabled={isPending}>{isPending ? 'Subscribing…' : 'Subscribe'}</button>{state.error && <p role="alert">{state.error}</p>}</form>)}
📚 In depth in the lesson Forms and React 19 Actions.
Rendering isn't expensive – unnecessary DOM changes and heavy computations are. React DOM changes only what differs. Before you start memoising, measure the problem in the React DevTools Profiler and try to solve it with structure:
memo, useMemo, useCallback (or the React Compiler, which does it for you).// ❌ Typing into the input re-renders the expensive chart toofunction Dashboard() {const [query, setQuery] = useState('')return (<><input value={query} onChange={(e) => setQuery(e.target.value)} /><ExpensiveChart /></>)}// ✅ State moved down – ExpensiveChart doesn't re-render at all, without a single memofunction Dashboard() {return (<><SearchBox /> {/* query lives inside */}<ExpensiveChart /></>)}
| Tool | What it does | When |
|---|---|---|
memo(Comp) | skips the render when props are the same | an expensive component, often with the same props |
useMemo | remembers the result of a computation | an expensive computation, or a stable object for memo/a dependency |
useCallback | remembers a function | the function goes to a memo component or into dependencies |
useTransition | marks an update as non-urgent | switching a tab, filtering a large list |
useDeferredValue | a delayed copy of a value | you don’t own the value (a prop), you want a smooth input |
📚 In depth in the lesson Performance and rendering.
A component driven by a dozen props (showHeader, headerIcon, footerButtons…) grows with every new requirement. A component that accepts content through children and slots doesn't have to change at all.
// ❌ Configuration: every new variant = a new prop<Card title="Order" showIcon icon="cart" footerText="Pay" onFooterClick={pay} />// ✅ Composition: Card only handles the frame, whoever uses it supplies the content<Card><Card.Header><CartIcon /> Order</Card.Header><OrderItems /><Card.Footer><button onClick={pay}>Pay</button></Card.Footer></Card>
More patterns from the same family:
Tabs, Tabs.List, Tabs.Panel) – they share state through Context, the user assembles the structure.overflow and z-index), but stays in the React tree, so context and events work normally.📚 In depth in the lesson Component design patterns.
TypeScript helps most when a type describes only valid states. Several independent booleans and optional fields allow combinations that make no sense (isLoading and error at the same time). A discriminated union forbids them as you type.
// ❌ 2 × 2 × 2 combinations, only some of them validtype State = { isLoading: boolean; error?: string; data?: User[] }// ✅ Only four valid states; in each branch TS knows what is availabletype State =| { status: 'idle' }| { status: 'loading' }| { status: 'error'; error: string }| { status: 'success'; data: User[] }switch (state.status) {case 'success': return <UserList users={state.data} /> // data is definitely herecase 'error': return <p>{state.error}</p>}
A short syntax cheat sheet:
| Syntax | Meaning | Example |
|---|---|---|
: | the type of a value (variable, parameter, return value) | function f(id: string): User |
<…> | a generic type argument | useState<User | null>(null) |
| nothing | let TS infer – where possible, it’s the best choice | const [n, setN] = useState(0) |
satisfies | checks the type but keeps the exact shape | const routes = {…} satisfies Routes |
as | a “trust me” assertion – no runtime check, use sparingly | el as HTMLInputElement |
// Props, children and events in Reacttype ButtonProps = React.ComponentProps<'button'> & { variant?: 'primary' | 'ghost' }function Panel({ title, children }: { title: string; children: React.ReactNode }) { … }// You don't have to write the event type when the handler is inline – TS infers it<input onChange={(e) => setName(e.target.value)} />// A generic component: the relation between items and renderItem is held by the type Tfunction List<T>({ items, renderItem }: { items: T[]; renderItem: (item: T) => React.ReactNode }) { … }
📚 In depth in the lesson Where to write a type.
📚 In depth in the lesson TypeScript with React.
Everything that should survive a page refresh, the Back button or sending a link to a colleague belongs in the URL – not in useState. Typically: the current page, the detail ID, filters, sorting, search, the open tab.
// ❌ The filter is lost on reload and can't be sharedconst [category, setCategory] = useState('all')// ✅ The filter in search params: /products?category=shoes&sort=priceconst [params, setParams] = useSearchParams()const category = params.get('category') ?? 'all'const changeCategory = (c: string) =>setParams((prev) => { prev.set('category', c); return prev })
/products/:id) identify what is displayed, search params adjust how.<Outlet /> share a layout; a loader and an error element apply only to their part of the page.<Link>), use navigate() only after an action (e.g. after saving).📚 In depth in the lesson Routing (React Router).
Data from the server isn't your state – it's a copy of something that belongs to the server and can change there at any moment. It needs caching, request deduplication, retry on error, a refresh when you return to the window and invalidation after a change. Written by hand in useEffect you'll get it wrong; that's why TanStack Query (and similar libraries) exist.
// ✅ queryKey contains EVERYTHING the query depends on → a change = a new query/cache entryconst { data, isPending, error } = useQuery({queryKey: ['products', { category, page }],queryFn: () => api.products({ category, page }),staleTime: 60_000, // treat the data as fresh for a minute})// ✅ After a change on the server, invalidate the affected data, the UI refreshes by itselfconst queryClient = useQueryClient()const addProduct = useMutation({mutationFn: api.createProduct,onSuccess: () => queryClient.invalidateQueries({ queryKey: ['products'] }),})
📚 In depth in the lesson Loading data.
The principles work best when you go through them in this order – that's exactly how the final project came about:
| Situation | Reach for… | Not for… |
|---|---|---|
| A value can be computed from other data | a computation during render | useState + useEffect |
| The user clicked and something should happen | code in a handler | an effect watching a flag |
| Hooking up a WebSocket, timer, DOM API | useEffect with cleanup | code directly in render |
| A value the user doesn’t see (a timer ID) | useRef | useState |
| Many different changes to one piece of state | useReducer | five setters in handlers |
| A theme / user needed deep in the tree | Context | prop drilling through 6 levels |
| A filter that should survive a reload | search params | useState |
| Data from an API | TanStack Query | fetch in useEffect |
| A slow render | the Profiler, then moving state down | memo everywhere “just in case” |
| A component with 15 props | composition via children | one more boolean |