State, effects and where data should live
Most React bugs on real screens are state in the wrong place: two copies of the same number that disagree, a filter that vanishes when you share a link, or an effect that shows the answer to an old search. This lesson gives you a short method for deciding where each piece of data lives, and shows the few jobs that really need an effect.
By the end you can
- Predict what a state update does, using snapshots, updater functions and batching
- Decide whether data is derived, server data, URL state, local state or lifted state
- Write a pure reducer with immutable updates for the stock adjustment panel
- Write an effect with the right dependencies and cleanup, and remove the effects you do not need
- Stop a fetch-in-effect race condition with an ignore flag or an
AbortController
The everyday picture
In a Mangrove store, every piece of information has one home. The stock count for rice lives in head office's system; the shelf label only shows it. The basket being scanned lives at one till and nobody else needs it. When the morning team wants the list of low-stock items, nobody keeps a second list on the back-room whiteboard, because the moment a delivery arrives that list is wrong. They look at the counts and the reorder levels and work it out again.
Some things should travel. If Ama writes "aisle 4, low stock only" on a request slip, she can photocopy it and hand it to anyone, and they see the same thing. And a few jobs mean picking up the phone to someone outside the store, like the supplier. You call, you talk, and you hang up when you are done. In React, those are derived values, server data, local state, URL state and effects.
State is a snapshot
useState gives a component a value that survives between renders, plus a function to change it. The key idea is that each render sees its own fixed copy of state, a snapshot. When you call the set function, React does not change the variable your code is holding. It queues an update and, after your event handler has finished, renders the component again with the new value.
That explains a classic surprise on the adjustment panel. A "+2" button that calls setDelta(delta + 1) twice adds 1, because both calls read the same delta from the same snapshot. If the next value depends on the previous one, pass an updater function instead:
const [delta, setDelta] = useState(0);
function addTwoWrong() {
setDelta(delta + 1); // delta is 0 in this render: queue "set to 1"
setDelta(delta + 1); // still 0: queue "set to 1" again
}
function addTwo() {
setDelta((d) => d + 1); // queue "add 1" to whatever is latest
setDelta((d) => d + 1); // 0, then 1, then 2
}React also batches updates: all the set calls in one event become one re-render. Since React 18 this happens everywhere, including inside timeouts and promise callbacks. And React compares the new value with the old one using Object.is. If you change an object in place and set the same object again, React sees the same reference and may skip the render. That is why state updates are immutable: you build a new object with the change (Objects, arrays and references shows the mechanism).
Quick check
delta is 0. A button runs for (let i = 0; i < 10; i++) setDelta(delta + 1). What is delta after one click?
All ten calls queue "set to 0 + 1". The loop runs, but delta is a fixed value inside this render. With setDelta((d) => d + 1) React would apply ten updaters one after another and reach 10.
Deciding where each piece of data lives
Before you write useState, ask five questions about the data, in this order. The first answer that fits wins.
- Can you compute it from props, other state or the URL? Then it is a derived value. Calculate it during render and store nothing.
- Does it come from the server? Stock rows, prices and order counts belong to the server. The screen holds a cached copy that it loads and refreshes (Loading server data). Do not copy them into
useStateand edit them there. - Should a link, a reload or the back button bring it back? Search text, filters, the current tab and the page number usually should. Put them in the URL's search params.
- Does only one component use it? An open dropdown, a half-typed note, a draft adjustment. Keep it local in that component.
- Do two components need it? Find their closest common parent, keep the state there, and pass it down as props, with functions to change it. This is called lifting state up.
Applied to the stock screen: the rows come from the server. The search text and the low-only box go in the URL, because Ama shares links with her area manager on WhatsApp or email. The adjustment draft is local to the panel. The selected article is used by the table (to highlight the row) and by the panel, so it lives in StockScreen, their common parent. The low-stock count and the filtered rows are derived.
The most common wrong answer is storing a derived value. It looks harmless:
// Avoid: a second copy of the rows that must be kept in sync by hand
const [rows, setRows] = useState<StockItem[]>([]);
useEffect(() => {
setRows(visibleItems(items, { q, lowOnly }));
}, [q]); // forgot items and lowOnly: rows go stale
// Better: compute during render. One source, always current.
const rows = visibleItems(items, { q, lowOnly });The first version renders twice for every change (once with the old rows, then again after the effect), and it is wrong whenever someone forgets a dependency. Filtering 2,000 rows during render usually takes far less time than one frame. If you measure that a calculation is slow, wrap it in useMemo; the React Compiler can add this kind of memoisation for you. Either way, the value stays derived.
Quick check
The screen's header says "Stock: 7 low". Where should the 7 come from?
The count can be computed from the rows, so it is derived. The handler version breaks the day someone adds a new way to change stock and forgets the count. The effect version renders once with the old count and then again, and is only as correct as its dependency array.
StockScreen reads them and the server's rows and derives what to show, and the adjustment draft stays inside the panel. Nothing is stored twice.A reducer for the adjustment panel
The adjustment panel has a delta, a reason and a note, and several buttons change them: +1, -1, +10, -10, pick a reason, type a note, clear. Spread across four useState calls, the rules ("the delta stays between -500 and 500", "clear keeps the article") end up copied into every handler. useReducer moves them into one function.
A reducer takes the current state and an action, a plain object describing what happened, and returns the next state. The component calls dispatch({ type: 'stepped', by: 10 }) and does not care how the state changes. Two rules apply. The reducer must be pure: no fetches, no timers, no random numbers, the same input always gives the same output. In development, StrictMode calls it twice to help you catch impure ones. And it must return a new object whenever something changed, never edit state in place:
case 'stepped': {
// Wrong: state.delta += action.by; return state; (same object, React may skip the render)
const delta = Math.max(-500, Math.min(500, state.delta + action.by));
return { ...state, delta }; // a new object with the change
}A pure reducer is also the easiest code in the app to test. It needs no React, no browser and no mocks, which is why the Practice section tests one in your browser. Use useState for one or two independent values, and a reducer when several events change related values under shared rules.
Effects synchronise with things outside React
An effect is code that runs after React has updated the screen. Its job is to keep the component in step with an external system: a timer, a browser event, a WebSocket, a map or chart library that draws on its own. An effect has three parts: the setup code, an optional cleanup function it returns, and a dependency array.
The stock rows show "updated 3 min ago" from each row's updatedAt, and the text should move on even when nothing else changes. A clock is outside React, so this is a real effect:
function useNow(everyMs: number) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), everyMs); // setup: start the timer
return () => clearInterval(id); // cleanup: stop it
}, [everyMs]); // re-sync if the interval changes
return now;
}The lifecycle is always the same. After the first render, React runs the setup. After each later render, it compares every dependency with its previous value. If one changed, React runs the old cleanup and then the new setup. When the component leaves the screen, it runs the last cleanup. With no array the effect runs after every render; with [] it runs once after the component appears. In development, StrictMode runs setup, cleanup and setup again once when the component first appears. If that double run breaks something, your cleanup is missing or incomplete.
For subscribing to a browser value such as "is the store PC online?", React has a purpose-built hook, useSyncExternalStore, which handles the subscription and cleanup for you:
function subscribe(onChange: () => void) {
window.addEventListener('online', onChange);
window.addEventListener('offline', onChange);
return () => {
window.removeEventListener('online', onChange);
window.removeEventListener('offline', onChange);
};
}
export function useOnline() {
return useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
}The React docs page "You Might Not Need an Effect" lists the cases where an effect is the wrong tool. Three come up on almost every screen:
- Data you can compute. Filtered rows, totals, "after this change" counts: compute them during render, as above.
- Resetting state when a prop changes. When Ama moves from rice to oil, the panel's draft should start fresh. An effect that watches
itemand clears the state renders the stale draft first. Give the panel a key instead:<AdjustPanel key={item.articleId} … />. A new key is a new component with fresh state. - Things the user did. Saving an adjustment happens because Ama pressed Save, so the request belongs in the click or submit handler. An effect that watches a
submittedflag runs again at odd moments, such as the StrictMode double run or a re-render, and can send the same adjustment twice.
Quick check
Which of these is a job for an effect?
The chart library is an external system that React does not control, so you set it up in an effect and destroy it in the cleanup. The low-stock count is derived data, so compute it during render. Saving is caused by a click, so it belongs in the event handler.
The race in fetch-in-effect
Fetching in an effect is allowed: the network is an external system. It has a trap. Each change of the search text starts a new request, and requests do not come back in the order you sent them. On a store's weak mobile connection, an older, slower answer can arrive after the newer one and overwrite it. This is a race condition. The cleanup fixes it, because React runs the old effect's cleanup before starting the new one:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/stores/${storeId}/stock?q=${encodeURIComponent(q)}`, { signal: controller.signal })
.then((res) => res.json())
.then((rows: StockItem[]) => setItems(rows))
.catch((err) => {
if (err.name !== 'AbortError') setError(err); // an aborted request is not an error
});
return () => controller.abort(); // a newer search cancels this one
}, [storeId, q]);The React docs show the same idea with an ignore flag: the setup declares let ignore = false, the cleanup sets it to true, and the code calls setItems only if !ignore. The flag stops the old answer from reaching the screen; AbortController also cancels the download, which saves data on a slow connection. Use both ideas together if your fetch helper cannot take a signal.
Even with the race fixed, this effect still has no caching, no loading state shared with other screens, and it waits for the JavaScript to load before it starts. That is why Loading server data moves the stock list out of effects into Server Components, and uses a data library for the live order counts.
milk request leaves at 0 s and answers at 5 s; the oil request leaves at 2 s and answers at 3 s. Without cleanup, the late milk answer overwrites the oil rows while the box still says "oil". With an abort or ignore flag in the cleanup, only the latest search can reach the screen.Build it
Add the search and the low-only filter to the stock screen, stored in the URL, and an adjustment panel driven by a reducer. This uses the Next.js 16 App Router; the rows come from a sample file for now, and Loading server data replaces it with a server fetch.
-
Pure helpers for the stock rows
Keep the logic in plain functions, outside components, so you can test it without React.
TypeScript// app/stock/stock.ts export type StockItem = { articleId: string; name: string; category: string; onHand: number; reorderLevel: number; priceCents: number; updatedAt: string; }; export type Filters = { q: string; lowOnly: boolean }; export const isLow = (item: StockItem) => item.onHand <= item.reorderLevel; // The URL is the source of the filters; this turns it into a typed object export function readFilters(params: { get(name: string): string | null }): Filters { return { q: params.get('q') ?? '', lowOnly: params.get('lowOnly') === '1' }; } export function visibleItems(items: StockItem[], { q, lowOnly }: Filters): StockItem[] { const needle = q.trim().toLowerCase(); return items .filter((item) => !lowOnly || isLow(item)) .filter((item) => needle === '' || item.name.toLowerCase().includes(needle) || item.articleId.toLowerCase() === needle) .sort((a, b) => a.name.localeCompare(b.name)); // filter made a new array, so sorting it is safe } -
The adjustment reducer
TypeScript// app/stock/adjust.ts export type Reason = 'damaged' | 'expired' | 'count-correction' | 'theft' | 'received'; export type Draft = { articleId: string; delta: number; reason: Reason; note: string }; export type DraftAction = | { type: 'stepped'; by: number } | { type: 'reasonPicked'; reason: Reason } | { type: 'noteTyped'; note: string } | { type: 'reset' }; export const MAX_DELTA = 500; export const emptyDraft = (articleId: string): Draft => ({ articleId, delta: 0, reason: 'damaged', note: '' }); export function adjustReducer(state: Draft, action: DraftAction): Draft { switch (action.type) { case 'stepped': { const delta = Math.max(-MAX_DELTA, Math.min(MAX_DELTA, state.delta + action.by)); return { ...state, delta }; // new object, so React sees the change } case 'reasonPicked': return { ...state, reason: action.reason }; case 'noteTyped': return { ...state, note: action.note }; case 'reset': return emptyDraft(state.articleId); // clearing keeps the article default: return state; } } -
Write the filters to the URL from the search bar
The filters change because the user typed or ticked, so the URL is updated in the event handlers, with no effect.
router.replacechanges the URL without adding a history entry, so the back button does not step through every letter.scroll: falsekeeps the page where it is.TypeScript// app/stock/SearchBar.tsx 'use client'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; export function SearchBar() { const searchParams = useSearchParams(); const pathname = usePathname(); const router = useRouter(); function setParam(name: 'q' | 'lowOnly', value: string) { const params = new URLSearchParams(searchParams.toString()); if (value) params.set(name, value); else params.delete(name); // default filters keep the link short const query = params.toString(); router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); } return ( <div role="search"> <label> Search <input type="search" defaultValue={searchParams.get('q') ?? ''} onChange={(e) => setParam('q', e.target.value)} /> </label> <label> <input type="checkbox" defaultChecked={searchParams.get('lowOnly') === '1'} onChange={(e) => setParam('lowOnly', e.target.checked ? '1' : '')} /> Low stock only </label> </div> ); } -
The panel with useReducer
The third argument of
useReducerbuilds the first state from the article id. "After this change" is derived, never stored. Saving the draft is Forms that people can trust.TypeScript// app/stock/AdjustPanel.tsx 'use client'; import { useReducer } from 'react'; import { adjustReducer, emptyDraft, type Reason } from './adjust'; import type { StockItem } from './stock'; const REASONS: Reason[] = ['damaged', 'expired', 'count-correction', 'theft', 'received']; export function AdjustPanel({ item, onClose }: { item: StockItem; onClose: () => void }) { const [draft, dispatch] = useReducer(adjustReducer, item.articleId, emptyDraft); const after = item.onHand + draft.delta; // derived const needsNote = draft.reason === 'count-correction' || draft.reason === 'theft'; return ( <section aria-label={`Adjust ${item.name}`}> <h2>{item.name}</h2> <p>On hand {item.onHand}, after this change {after}</p> {[-10, -1, 1, 10].map((by) => ( <button key={by} type="button" onClick={() => dispatch({ type: 'stepped', by })}> {by > 0 ? `+${by}` : by} </button> ))} <select aria-label="Reason" value={draft.reason} onChange={(e) => dispatch({ type: 'reasonPicked', reason: e.target.value as Reason })}> {REASONS.map((r) => <option key={r} value={r}>{r}</option>)} </select> {needsNote && ( <textarea aria-label="Note" value={draft.note} onChange={(e) => dispatch({ type: 'noteTyped', note: e.target.value })} /> )} <button type="button" onClick={() => dispatch({ type: 'reset' })}>Clear</button> <button type="button" onClick={onClose}>Close</button> </section> ); } -
The screen: URL state, local state, derived values
The selection is stored as an id, so the panel always shows the current row from the server data, never an old copy. The
keygives each article a fresh draft.TypeScript// app/stock/StockScreen.tsx 'use client'; import { useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { readFilters, visibleItems, isLow, type StockItem } from './stock'; import { SearchBar } from './SearchBar'; import { AdjustPanel } from './AdjustPanel'; export function StockScreen({ items }: { items: StockItem[] }) { const searchParams = useSearchParams(); const filters = readFilters(searchParams); // URL state const [selectedId, setSelectedId] = useState<string | null>(null); // local state const rows = visibleItems(items, filters); // derived const lowCount = items.filter(isLow).length; // derived const selected = items.find((i) => i.articleId === selectedId) ?? null; return ( <main> <h1>Stock: {lowCount} low</h1> <SearchBar /> <table> <thead><tr><th>Item</th><th>On hand</th><th>Reorder at</th><th></th></tr></thead> <tbody> {rows.map((item) => ( <tr key={item.articleId} className={item.articleId === selectedId ? 'selected' : undefined}> <td>{item.name}</td><td>{item.onHand}</td><td>{item.reorderLevel}</td> <td><button type="button" onClick={() => setSelectedId(item.articleId)}>Adjust</button></td> </tr> ))} </tbody> </table> {selected && ( <AdjustPanel key={selected.articleId} item={selected} onClose={() => setSelectedId(null)} /> )} </main> ); } -
The page, with a Suspense boundary
The page is a Server Component. A Client Component that calls
useSearchParamson a prerendered page must sit inside<Suspense>; without it, the production build fails with "Missing Suspense boundary with useSearchParams". In development it works either way, so add the boundary now.TypeScript// app/stock/page.tsx import { Suspense } from 'react'; import { StockScreen } from './StockScreen'; import { sampleStock } from './sample-stock'; // a dozen StockItem rows; fe-03 fetches real ones export default function StockPage() { return ( <Suspense fallback={<p>Loading stock…</p>}> <StockScreen items={sampleStock} /> </Suspense> ); } -
Try it
Run
npm run devand open/stock. Typeoiland tick "Low stock only": the address bar becomes/stock?q=oil&lowOnly=1. Copy it into a new tab, and the same view opens. Open the panel for one item, press +10 twice, then open another item: the draft starts at 0, thanks to the key. Press reload: the filters survive, because they were never component state.
Bring it to your team
Questions for your next front-end code review.
- Is this value stored anywhere else already? Could we compute it during render instead?
- If Ama shares this link or presses reload, does she see the same filters?
- What external system does this effect synchronise with? If none, why is it an effect?
- Does every effect clean up: timers cleared, listeners removed, old requests aborted or ignored?
- Is the dependency array honest, or was a value left out to stop a loop?
- Are reducers pure, with immutable updates and their own unit tests?
Red flags
useEffect(() => setSomething(compute(a, b)), [a]): derived state with a missing dependency.- Props copied into
useStateand then "kept in sync" with an effect. - A fetch in an effect with no cleanup, on a screen people use on slow connections.
- Filters that live only in component state on a screen people share.
- Turning off StrictMode because "effects run twice".
Practice
Fix the adjustment reducer
The starter reducer for the adjustment panel changes state in place and returns the same object, so React would not re-render. Rewrite it with immutable updates. Actions: { type: 'stepped', by } adds by to delta and keeps it between -500 and 500; { type: 'reasonPicked', reason } and { type: 'noteTyped', note } set those fields; { type: 'reset' } returns { articleId, delta: 0, reason: 'damaged', note: '' } for the same article; any other action returns the same state object.
function adjustReducer(state, action) {
switch (action.type) {
case 'stepped':
state.delta += action.by;
return state;
case 'reasonPicked':
state.reason = action.reason;
return state;
case 'noteTyped':
state.note = action.note;
return state;
case 'reset':
state.delta = 0;
return state;
default:
return state;
}
}
const start = () => ({ articleId: 'A2040', delta: 0, reason: 'damaged', note: '' });
test('stepped returns a new object and leaves the old state unchanged (React compares references)', () => {
const before = start();
const after = adjustReducer(before, { type: 'stepped', by: 10 });
expect(after === before).toBe(false);
expect(after.delta).toBe(10);
expect(before.delta).toBe(0);
});
test('delta stays between -500 and 500', () => {
let s = start();
for (let i = 0; i < 60; i++) s = adjustReducer(s, { type: 'stepped', by: 10 });
expect(s.delta).toBe(500);
expect(adjustReducer({ ...start(), delta: -495 }, { type: 'stepped', by: -10 }).delta).toBe(-500);
});
test('reasonPicked and noteTyped set their field in a new object and keep the rest', () => {
const before = { ...start(), delta: -6 };
const a = adjustReducer(before, { type: 'reasonPicked', reason: 'theft' });
const b = adjustReducer(a, { type: 'noteTyped', note: 'shelf 4' });
expect(b).toEqual({ articleId: 'A2040', delta: -6, reason: 'theft', note: 'shelf 4' });
expect(before).toEqual({ articleId: 'A2040', delta: -6, reason: 'damaged', note: '' });
});
test('reset clears delta, reason and note but keeps the article', () => {
const dirty = { articleId: 'A2040', delta: 40, reason: 'theft', note: 'x' };
expect(adjustReducer(dirty, { type: 'reset' })).toEqual(start());
expect(dirty.delta).toBe(40);
});
test('an unknown action returns the same state object', () => {
const s = start();
expect(adjustReducer(s, { type: 'nothing' }) === s).toBe(true);
});
Show a solution
function adjustReducer(state, action) {
switch (action.type) {
case 'stepped': {
const delta = Math.max(-500, Math.min(500, state.delta + action.by));
return { ...state, delta };
}
case 'reasonPicked':
return { ...state, reason: action.reason };
case 'noteTyped':
return { ...state, note: action.note };
case 'reset':
return { articleId: state.articleId, delta: 0, reason: 'damaged', note: '' };
default:
return state;
}
}Derive the visible rows
Write visibleItems(items, { q, lowOnly }). It returns a new array of the items to show, sorted by name (use a.name.localeCompare(b.name)). An item matches the search if its name contains q or its articleId equals q, ignoring case and spaces around q; an empty q matches everything. With lowOnly, keep only low items: onHand <= reorderLevel. Never change the array you were given. The starter has three bugs.
function visibleItems(items, { q, lowOnly }) {
items.sort((a, b) => a.name.localeCompare(b.name));
return items
.filter((item) => !lowOnly || item.onHand < item.reorderLevel)
.filter((item) => item.name.includes(q) || item.articleId === q);
}
const items = [
{ articleId: 'A2040', name: 'Sunflower oil 2 L', onHand: 4, reorderLevel: 12 },
{ articleId: 'A1001', name: 'Rice 5 kg', onHand: 30, reorderLevel: 20 },
{ articleId: 'A3310', name: 'Mustard oil 1 L', onHand: 8, reorderLevel: 8 },
{ articleId: 'A0520', name: 'Milk 1 L', onHand: 50, reorderLevel: 40 },
];
const names = (rows) => rows.map((r) => r.name);
test('an empty search shows every item, sorted by name', () => {
expect(names(visibleItems(items, { q: '', lowOnly: false })))
.toEqual(['Milk 1 L', 'Mustard oil 1 L', 'Rice 5 kg', 'Sunflower oil 2 L']);
});
test('search ignores case and spaces, and also matches an article id', () => {
expect(names(visibleItems(items, { q: ' OIL ', lowOnly: false }))).toEqual(['Mustard oil 1 L', 'Sunflower oil 2 L']);
expect(names(visibleItems(items, { q: 'a1001', lowOnly: false }))).toEqual(['Rice 5 kg']);
});
test('low means onHand <= reorderLevel, so an item exactly at its level is low', () => {
expect(names(visibleItems(items, { q: '', lowOnly: true }))).toEqual(['Mustard oil 1 L', 'Sunflower oil 2 L']);
});
test('does not reorder or change the array it was given (sort a copy)', () => {
const copy = items.slice();
visibleItems(copy, { q: '', lowOnly: false });
expect(names(copy)).toEqual(['Sunflower oil 2 L', 'Rice 5 kg', 'Mustard oil 1 L', 'Milk 1 L']);
});
Show a solution
function visibleItems(items, { q, lowOnly }) {
const needle = q.trim().toLowerCase();
return items
.filter((item) => !lowOnly || item.onHand <= item.reorderLevel)
.filter((item) => needle === ''
|| item.name.toLowerCase().includes(needle)
|| item.articleId.toLowerCase() === needle)
.sort((a, b) => a.name.localeCompare(b.name)); // filter returned a new array
}Check yourself
Ama opens the adjustment panel for rice, presses +10, then clicks Adjust on cooking oil. The panel now shows cooking oil with a delta of +10 left over from rice. What is the best fix?
A different key makes React treat it as a different component, so its state starts fresh with no extra render. The effect also works, but it first renders the old draft with the new item, and it adds a dependency to get wrong. Copying the item into state creates a stale second copy of server data.
The stock table highlights the selected row, and the adjustment panel shows the selected item. The team does not want links to open the panel. Where should selectedId live?
Two siblings need the same value, so lift it to the closest common parent: one copy, passed down. Two copies synced by effects lag by a render and drift apart. The selection is one user's screen state, so it does not belong in shared server data.
In development, the network panel shows the stock request twice when the screen opens. In production it appears once. What is happening, and what should you do?
The extra cycle is on purpose: it shows whether your cleanup undoes your setup. With an AbortController or an ignore flag, the first request is cancelled or ignored and the screen is correct. Turning StrictMode off hides the missing cleanup until the race appears in a store.
Flashcards
Key takeaways
- State is a snapshot per render. Use an updater function when the next value depends on the last, and always update immutably.
- Ask in order: derived, server data, URL, local, lifted. Store each piece of data in one place only.
- Shareable filters belong in the URL; drafts with one owner stay local; use a pure reducer when several events change related values.
- Effects synchronise with external systems, with honest dependencies and a cleanup. Derived data, key resets and user events do not need one.
- Fetch in an effect only with an abort or ignore flag, and prefer moving server data out of effects.
Sources
- State as a snapshot, React documentation
- Sharing state between components, React documentation
- Extracting state logic into a reducer, React documentation
- Synchronizing with Effects, React documentation
- You Might Not Need an Effect, React documentation
- useSearchParams, Next.js documentation
- AbortController, MDN Web Docs