Luca Mele
Luca Mele

React

React Do's and Don'ts: Fiber, Reconciliation, and the Hooks Nobody Uses Right

React Do's and Don'ts: Fiber, Reconciliation, and the Hooks Nobody Uses Right
Back to all posts
·9 min read
Listen to the AI-generated podcast
All podcasts →

I've reviewed hundreds of React pull requests across multiple teams. The same mistakes come up over and over: useMemo wrapped around a string concatenation, key={index} on a filterable list, useEffect chains that could be a single derived value. These aren't junior mistakes — senior engineers make them too, because React's mental model is deceptively simple on the surface.

This article covers the internals that matter for daily work: how the Fiber tree works, what reconciliation actually does, and the hooks patterns that most developers get wrong. No theory for its own sake — every section is a decision you'll face in your next PR.

The Fiber Tree: What React Actually Builds

When you write JSX, you're not writing HTML. You're describing a tree of JavaScript objects. React takes that description and builds a Fiber tree — a linked list of "work units" where each node represents one element or component in your UI.

Every Fiber node holds pointers to its child, sibling, and parent. It holds the component's props, its hooks state (as a linked list — that's why hook order matters), and a reference to the actual DOM node it represents. Critically, React maintains two Fiber trees at all times: the "current" tree that's on screen, and a "work-in-progress" tree being constructed during a render.

Why does this matter? Because when React "re-renders," it doesn't touch the DOM yet. It builds a new work-in-progress Fiber tree, compares it to the current tree, calculates the minimal set of DOM changes needed, and then applies them in one batch. This is reconciliation.

// React doesn't work with the DOM directly.
// It maintains a Fiber tree — a linked list of "work units"

// Each Fiber node looks roughly like this:
interface FiberNode {
  type: 'div' | 'span' | typeof MyComponent; // what to render
  key: string | null;
  props: Record<string, unknown>;
  stateNode: HTMLElement | null;     // the actual DOM node (or component instance)
  child: FiberNode | null;           // first child
  sibling: FiberNode | null;         // next sibling
  return: FiberNode | null;          // parent
  alternate: FiberNode | null;       // the "other" tree (current vs work-in-progress)
  effectTag: 'PLACEMENT' | 'UPDATE' | 'DELETION';
  memoizedState: unknown;            // hooks live here, as a linked list
}

// When you write JSX, React builds this tree — NOT a DOM tree.
// <App>                    Fiber: App
//   <Header />             Fiber: Header  (child of App)
//   <main>                 Fiber: main    (sibling of Header)
//     <Article />          Fiber: Article (child of main)
//   </main>
// </App>
// Each Fiber holds its own hooks state (memoizedState),
// a pointer to its DOM node, and links to parent/child/sibling.

Reconciliation: How React Decides What Changes

Reconciliation is React's diffing algorithm. It walks both Fiber trees (current and work-in-progress) and applies three rules to decide what to do with each node.

Rule 1: if the element type changed (div to span, ComponentA to ComponentB), React tears down the entire subtree and rebuilds it from scratch. All state is lost. All children are unmounted and remounted. This is why wrapping a component in a conditional that switches between two different component types is expensive — it's not an update, it's a full destruction and reconstruction.

Rule 2: if the element type is the same, React keeps the instance alive and just updates the changed props. This is the fast path. A button changing from className="blue" to className="red" is a single DOM attribute update.

Rule 3: for lists of children, React uses keys to match elements between the old and new tree. Without keys, it matches by index — which means prepending an item causes every existing item to be "updated" with the wrong data. With stable keys, React identifies which items moved, which were added, and which were removed, and applies the minimal set of operations.

// Reconciliation = React's diffing algorithm
// It compares the OLD Fiber tree with the NEW one, node by node.

// Rule 1: Different type? Tear down and rebuild.
// Old: <div><Counter /></div>
// New: <span><Counter /></span>
// Result: entire <div> subtree destroyed, <span> subtree created from scratch.
// Counter loses ALL state — even if the component is identical.

// Rule 2: Same type? Update props, keep the instance.
// Old: <button className="blue" onClick={handleA}>Save</button>
// New: <button className="red" onClick={handleB}>Save</button>
// Result: React updates className and onClick on the existing DOM node.
// No unmount, no remount. Fast.

// Rule 3: Lists need keys for identity.
// Without keys, React matches by index:
// Old: [Alice, Bob, Charlie]
// New: [Zara, Alice, Bob, Charlie]  // prepended
// React thinks: Alice→Zara (update), Bob→Alice (update), Charlie→Bob (update), +Charlie (insert)
// That's 4 operations instead of 1 insert.

// With keys:
// Old: [Alice(1), Bob(2), Charlie(3)]
// New: [Zara(0), Alice(1), Bob(2), Charlie(3)]
// React matches by key: Alice, Bob, Charlie are unchanged. Just insert Zara.
// 1 operation. That's why keys exist.

Keys: More Than Just "Use a Unique ID"

Everyone knows to use key={item.id} on lists. But keys have a deeper purpose that most developers miss: they control component identity in the Fiber tree.

When a key changes, React treats it as a completely different component — even if the type is the same. The old instance is unmounted (all state reset), and a new one is mounted. This is incredibly useful for resetting component state without useEffect hacks.

The classic example: a form that should reset when the user switches to a different record. Instead of a useEffect that watches the ID and manually resets state (which is fragile and creates intermediate states), just key the form on the record ID. When the ID changes, React does a clean unmount/remount. All useState calls return their initial values. All useEffect cleanups run. It's a complete fresh start, with zero code inside the component.

// DON'T: key={index} on a list that reorders, filters, or prepends
// This destroys the performance benefit AND causes state bugs
{items.map((item, i) => (
  <TodoItem key={i} item={item} />  // index shifts when items move
))}

// DO: key={item.id} — a stable identity that follows the data
{items.map((item) => (
  <TodoItem key={item.id} item={item} />
))}

// TRICK: Use key to FORCE a full remount when you want to reset state
// This is an intentional, legitimate use of reconciliation:
function EditForm({ userId }: { userId: string }) {
  // Changing userId doesn't reset internal form state...
  const [name, setName] = useState('');
  // ...because React sees the same component type at the same position
}

// Fix: key the component on the identity that should trigger a reset
<EditForm key={userId} userId={userId} />
// Now when userId changes, React unmounts the old EditForm
// and mounts a fresh one — all state resets automatically.
// No useEffect cleanup, no stale state bugs.

useMemo: When It Actually Helps (And When It's Waste)

useMemo is the most overused hook in React. I've seen codebases where every computed value is wrapped in useMemo "for performance." This is backwards — useMemo has overhead. It stores the previous result and dependencies, compares the dependencies on every render, and returns the cached value if nothing changed. For cheap computations, the comparison costs more than just recalculating.

The rule is simple: useMemo is for expensive computations (sorting large arrays, complex data transformations, parsing) or for stabilizing references that are passed to React.memo children. That second part is critical — useMemo without React.memo on the receiving component is almost always pointless, because the child re-renders anyway when its parent re-renders.

Think of useMemo + React.memo as a pair. useMemo stabilizes the reference. React.memo on the child skips the re-render when props haven't changed. Remove either piece and the optimization doesn't work.

// DON'T: useMemo everything "just in case"
// This is SLOWER than no memo for cheap operations:
const fullName = useMemo(() => first + ' ' + last, [first, last]);
// The comparison of [first, last] costs more than the concatenation.

// DON'T: useMemo on object literals passed to children
// unless the child is wrapped in React.memo
const style = useMemo(() => ({ color: 'red' }), []);
// If <Child> isn't React.memo'd, it re-renders anyway when its parent does.
// The stable reference achieves nothing.

// DO: useMemo for genuinely expensive computations
const sortedItems = useMemo(
  () => items.toSorted((a, b) => a.score - b.score),
  [items]
);
// Sorting 10,000 items on every render is real waste.

// DO: useMemo + React.memo as a pair
const MemoizedChild = React.memo(ExpensiveChart);

function Dashboard({ data }: { data: DataPoint[] }) {
  const processed = useMemo(() => transformData(data), [data]);
  // useMemo keeps the reference stable
  // React.memo on the child skips re-render if props are the same
  return <MemoizedChild data={processed} />;
  // BOTH pieces are needed. useMemo alone does nothing visible.
  // React.memo alone doesn't help if the parent creates new objects each render.
}

useCallback: The Same Rules Apply

useCallback is literally useMemo(() => fn, deps) under the hood. The same rules apply: it's only useful when the stable reference actually prevents work downstream — which means the recipient must be wrapped in React.memo, or the function is used in a useEffect dependency array.

I enforce a simple team rule: don't use useCallback unless you can point to the React.memo or useEffect that benefits from it. If you can't, you're adding complexity for zero performance gain.

// useCallback has the SAME rules as useMemo — it's literally
// useMemo(() => fn, deps) under the hood.

// DON'T: wrap every handler in useCallback
const handleClick = useCallback(() => {
  doSomething(id);
}, [id]);
// Unless <Child> is React.memo, this is pure overhead.

// DO: useCallback when passing to a memoized child
const MemoButton = React.memo(Button);

function Toolbar({ onSave }: { onSave: () => void }) {
  // If the parent re-renders, a new arrow function is created each time
  // React.memo on Button would see a new prop and re-render anyway
  // useCallback keeps the same function reference across renders
  const handleSave = useCallback(() => onSave(), [onSave]);
  return <MemoButton onClick={handleSave}>Save</MemoButton>;
}

// DO: useCallback for functions used in useEffect dependencies
function useAutoSave(data: Data) {
  const save = useCallback(() => {
    api.save(data);
  }, [data]);

  useEffect(() => {
    const id = setInterval(save, 5000);
    return () => clearInterval(id);
  }, [save]); // stable reference prevents re-creating the interval
}

Lazy State Initialization: The One-Line Fix

useState accepts either a value or a function. When you pass a function, React calls it only on the initial mount — not on every render. This matters when the initial value is expensive to compute: reading from localStorage, parsing JSON, creating class instances.

I see this mistake constantly: useState(JSON.parse(localStorage.getItem(key)!)). That parse runs on every render, but only the first result is used. The fix is one character: useState(() => JSON.parse(localStorage.getItem(key)!)). The arrow function makes it lazy.

// DON'T: expensive computation runs on EVERY render
const [state, setState] = useState(parseJsonFromStorage(key));
// parseJsonFromStorage runs every render, but only the first result is used.

// DO: pass a function — React calls it only on mount
const [state, setState] = useState(() => parseJsonFromStorage(key));
// The () => wrapper makes it lazy. Runs once.

// Same pattern with useRef — but useRef has no lazy initializer:
// DON'T:
const worker = useRef(new HeavyWorker()); // created every render, discarded

// DO:
const workerRef = useRef<HeavyWorker | null>(null);
function getWorker() {
  if (!workerRef.current) workerRef.current = new HeavyWorker();
  return workerRef.current;
}
// Lazy singleton pattern. Created on first access, not on every render.

useRef Beyond the DOM

useRef is underused. Most developers think it's only for accessing DOM elements. But its real power is as a mutable container that persists across renders without triggering re-renders. Any value that needs to survive renders but shouldn't cause a re-render when it changes belongs in a ref.

The patterns below are the ones I use most: tracking previous values, creating stable callbacks that always call the latest function version (solving the stale closure problem), and building intervals/timeouts that don't need to be re-created when their callback changes.

// useRef is NOT just for DOM elements.
// It's a mutable container that survives re-renders without causing them.

// Track previous value (no re-render, no useEffect)
function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T | undefined>();
  useEffect(() => { ref.current = value; });
  return ref.current;
}

// Stable callback that always calls the latest version
// without re-triggering effects that depend on it
function useStableCallback<T extends (...args: never[]) => unknown>(fn: T): T {
  const ref = useRef(fn);
  ref.current = fn; // always up to date, no dependency array needed
  return useCallback(
    ((...args) => ref.current(...args)) as T,
    [] // genuinely stable — never changes
  );
}

// Avoid stale closures in intervals/timeouts
function useInterval(callback: () => void, ms: number) {
  const callbackRef = useRef(callback);
  callbackRef.current = callback; // always fresh

  useEffect(() => {
    const id = setInterval(() => callbackRef.current(), ms);
    return () => clearInterval(id);
  }, [ms]); // only re-creates when interval changes, not when callback does
}

The Rules That Actually Matter

After years of leading React teams, these are the rules I enforce. Don't useMemo or useCallback unless you can name the React.memo or useEffect that benefits. Use key to control identity — reset state by changing keys, not by useEffect. Never use index as key on dynamic lists. Pass functions to useState for expensive initial values. Put non-rendering values in refs, not state. Derive values during render instead of syncing them with useEffect.

Most React performance problems aren't solved by memoizing more — they're solved by rendering less. Move state down to where it's used. Split components so that a state change in a header doesn't re-render the entire page. Use children props so wrapper components don't re-render their content. These structural changes beat any amount of useMemo.