The React Knowledge

Introduction

Most React tutorials taught me the same loop. useState. useEffect. fetch inside a useEffect. Props down, callbacks up. Maybe a bit of useContext if they’re feeling adventurous. I followed along, I build something, and it works.

Then I hit a production bug where a stale closure has captured the wrong value. Or the component re-renders seventeen times on a single interaction and I didn’t know why. Or I added useEffect to fix a problem and created two new ones. Or I inherited a codebase where useContext was used for everything and the component tree re-renders on every keystroke.

The tutorial covered the API and stopped before the model.

I’ve been writing React professionally across small to mid level enterprise projects. React has changed significantly from the class component era through hooks, through concurrent features, through Server Components, all the way to v19. This article covers what I actually know — the internals that explain the behaviour, the patterns that hold up in production, the library integrations that change how you think about data fetching, and the v19 features that are still settling into the ecosystem.

Reading this won’t make React easy. It will make it legible.

The Reconciler: What React Is Actually Doing

Before hooks, before components, before JSX — understand the reconciler. It is the core of React and every behaviour you’ll ever debug traces back to it.

The virtual DOM and diffing

React maintains a virtual representation of the DOM — a tree of JavaScript objects describing what the UI should look like. When state changes, React re-runs the component function, produces a new virtual tree, diffs it against the previous one, and applies only the differences to the real DOM.

The diffing algorithm operates on two assumptions that drive everything:

  1. Two elements of different types produce different trees. If a <div> becomes a <section>, React tears down the entire subtree and builds a new one. It does not try to reconcile them.

  2. The key prop identifies elements across renders. When a list re-renders, React uses key to match elements. Same key — update. Different key — destroy and recreate.

These two rules explain most of the surprising behaviour you’ll encounter.

// This unmounts and remounts the input every time isError changes
// because the element type changes
function Form({ isError }: { isError: boolean }) {
  return (
    <div>
      {
        isError ? (
          <p>Error state</p> // Different element type
        ) : (
          <input type="text" />
        ) // Different element type
      }
    </div>
  );
}

// This preserves the input — only props change
function Form({ isError }: { isError: boolean }) {
  return (
    <div>
      <input type="text" className={isError ? 'input--error' : ''} />
    </div>
  );
}

Key as an identity reset mechanism

Most developers know key prevents the “key warning” in lists. Fewer know it’s a deliberate identity tool.

When you give a component a new key, React treats it as a completely new component instance — unmounts the old one and mounts a new one with fresh state. This is genuinely useful:

// Every time userId changes, the profile component gets fresh state
// No need to reset internal state manually
function UserProfile({ userId }: { userId: string }) {
  return <ProfileForm key={userId} userId={userId} />;
}

// Without key, ProfileForm's internal state persists between userId changes
// With key, it's a fresh mount — state initialises from scratch

This pattern replaced a complex useEffect cleanup in a CarCam feature where the camera view needed to fully reset when switching between vehicles. Instead of managing the reset imperatively, we changed the key.

Render !== commit

React separates work into phases: render (computing what the UI should look like) and commit (applying changes to the DOM). In concurrent mode, React can start a render, pause it, throw it away, and start again — all before the commit phase. This has implications.

Component functions can run multiple times before their output is committed. Side effects in the render function — logging, mutations, network calls — can run more times than you expect. This is why side effects belong exclusively in useEffect, not in the component body.

// This may run multiple times in concurrent mode before committing
function BadComponent({ id }: { id: string }) {
  analytics.track('component_rendered', { id }); // Don't do this
  return <div>{id}</div>;
}

// Effects run after commit — only once per actual DOM update
function GoodComponent({ id }: { id: string }) {
  useEffect(() => {
    analytics.track('component_rendered', { id });
  }, [id]);
  return <div>{id}</div>;
}

Re-renders: The Complete Picture

Understanding exactly when React re-renders a component eliminates an entire category of performance bugs.

A component re-renders when:

  1. Its own state changes (useState, useReducer)
  2. Its parent re-renders — regardless of whether props changed
  3. A context it subscribes to changes
  4. Its key prop changes

That second point is the one that surprises people most. If a parent component re-renders, every child component function runs again, even if the child receives identical props. This is the default behaviour.

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
      <ExpensiveChild /> {/* Re-renders on every count change */}
    </div>
  );
}

function ExpensiveChild() {
  console.log('ExpensiveChild rendered');
  return <div>I am expensive</div>;
}

ExpensiveChild re-renders every time count changes even though it receives no props from Parent. The component function runs. If the output is the same virtual DOM, React won’t update the real DOM — but the component function still ran.

React.memo — what it does and what it doesn’t

React.memo wraps a component and skips re-rendering if props haven’t changed by shallow reference equality:

const ExpensiveChild = React.memo(function ExpensiveChild() {
  console.log('ExpensiveChild rendered');
  return <div>I am expensive</div>;
});

// Now ExpensiveChild only re-renders when its props change
// Since it receives no props, it never re-renders after mount

The shallow equality part is critical. React.memo compares props with ===. For primitive props, this works as expected. For objects and functions, it fails unless the references are stable:

function Parent() {
  const [count, setCount] = useState(0);

  // This creates a new object every render
  // React.memo won't help — the prop reference changes every time
  const config = { theme: 'dark' };

  // This creates a new function every render — same problem
  const handleClick = () => console.log('clicked');

  return <MemoizedChild config={config} onClick={handleClick} />;
}

This is where useMemo and useCallback become necessary — not as performance optimisations by default, but as tools to stabilise references when React.memo is in play.

useMemo and useCallback — the real use cases

The most common misuse of useMemo is using it for every computation, everywhere, as a default. Memoization has a cost — the cached value and the dependency comparison. For cheap computations, the memoization overhead exceeds the computation cost.

The real use cases:

// 1. Stabilising an object reference for React.memo or useEffect
const config = useMemo(
  () => ({ endpoint: apiUrl, timeout: 5000 }),
  [apiUrl] // Only recreate when apiUrl changes
);

// 2. Expensive computation that runs on every render
const processedData = useMemo(
  () => largeDataset.filter((item) => item.active).sort(byDate),
  [largeDataset] // O(n log n) — worth memoising
);

// 3. Stabilising a function reference for React.memo child
const handleStatusChange = useCallback(
  (id: string, status: Status) => {
    updateTicket(id, { status });
  },
  [updateTicket] // Recreate only when updateTicket changes
);

The rule I follow: don’t add useMemo or useCallback by default. Add them when you have a concrete reason — a React.memo boundary that’s being penetrated by unstable references, a useEffect dependency that’s causing excessive runs, a demonstrably expensive computation. Profile first, optimise second.

Hooks: The Deep Behaviour

useState — the batching change you need to know

Before React 18, state updates were batched inside React event handlers but not inside Promises, setTimeout, or native event handlers. You could trigger multiple re-renders from a single callback.

React 18 introduced automatic batching everywhere. Multiple setState calls in any context are batched into a single re-render:

// React 17: three separate re-renders
// React 18: one re-render — all three updates batched
setTimeout(() => {
  setLoading(false);
  setData(result);
  setError(null);
}, 0);

This is almost always what you want. In the rare case where you need to force an immediate re-render between updates, use flushSync:

import { flushSync } from 'react-dom';

flushSync(() => {
  setCount(1); // Commits immediately
});
// DOM is updated here
flushSync(() => {
  setFlag(true); // Commits immediately
});

useState initialiser function

When initial state is expensive to compute, pass a function — it runs only once:

// This runs parseConfig on every render
const [config, setConfig] = useState(parseConfig(rawConfig));

// This runs parseConfig only on mount
const [config, setConfig] = useState(() => parseConfig(rawConfig));

The function form is also useful for reading from localStorage on mount without causing hydration issues in server-rendered applications.

useReducer — when state logic belongs together

useReducer is not just useState for complex state. The distinction is conceptual: use useReducer when the next state depends on the current state in ways that involve multiple values or branching logic.

type State = {
  status: 'idle' | 'loading' | 'success' | 'error';
  data: Ticket[] | null;
  error: string | null;
};

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: Ticket[] }
  | { type: 'FETCH_ERROR'; error: string }
  | { type: 'RESET' };

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_START':
      return { status: 'loading', data: null, error: null };
    case 'FETCH_SUCCESS':
      return { status: 'success', data: action.payload, error: null };
    case 'FETCH_ERROR':
      return { status: 'error', data: null, error: action.error };
    case 'RESET':
      return { status: 'idle', data: null, error: null };
    default:
      return state;
  }
}

function TicketList() {
  const [state, dispatch] = useReducer(reducer, {
    status: 'idle',
    data: null,
    error: null,
  });

  // state.status === 'success' guarantees state.data is not null
  // The discriminated union in the type makes this safe
}

The discriminated union in the state type means TypeScript narrows correctly. When status === 'success', data is Ticket[], not null. When status === 'error', error is a string. Separate useState calls for loading, data, and error allow invalid combinations — all three truthy simultaneously, for instance. A reducer with a union state type prevents them.

useEffect — the model most developers are missing

useEffect is not a lifecycle method. It is a synchronisation mechanism. The question it answers is not “when should this run?” but “what external system does this component need to stay in sync with?”

This reframe changes how you write effects.

// Wrong framing: "run this when the component mounts"
useEffect(() => {
  fetchTickets();
}, []);

// Right framing: "keep this component in sync with server data"
// But actually — React Query does this better. More on that later.

// Legitimate sync use case: keep a third-party library in sync with React state
useEffect(() => {
  chartInstance.setData(data);
  chartInstance.setTheme(theme);
}, [data, theme]); // Re-sync whenever data or theme changes

The stale closure problem

The most common useEffect bug. A closure captures a value at the time of creation. If the effect’s dependencies aren’t exhaustive, the closure may reference an old value while the component has moved on.

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      // This closure captures count = 0 at mount time
      // It never sees the updated count
      console.log(count); // Always logs 0
    }, 1000);

    return () => clearInterval(timer);
  }, []); // Missing count in deps — stale closure
}

Two solutions:

// Solution 1: Add count to dependencies (effect recreates on every count change)
useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]);

// Solution 2: Use the function form of setState (no need to read count)
useEffect(() => {
  const timer = setInterval(() => {
    setCount((c) => c + 1); // Function form reads current value
  }, 1000);
  return () => clearInterval(timer);
}, []); // count is not needed here

The eslint-plugin-react-hooks rule exhaustive-deps catches missing dependencies at lint time. Enable it. Trust it.

useRef — beyond DOM references

useRef is commonly taught as “the way to access DOM nodes.” It is also the correct tool for any mutable value that needs to persist across renders without triggering re-renders:

function useInterval(callback: () => void, delay: number) {
  // Store the latest callback in a ref — avoids stale closure
  // without adding callback to the interval's dependency array
  const savedCallback = useRef(callback);

  useEffect(() => {
    savedCallback.current = callback;
  });

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }
    const id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]); // Only recreate the interval when delay changes
}
// Track previous value
function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>(undefined);
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

// Track whether this is the first render
function useIsFirstRender(): boolean {
  const isFirstRender = useRef(true);
  useEffect(() => {
    isFirstRender.current = false;
  }, []);
  return isFirstRender.current;
}

useLayoutEffect — the synchronous alternative

useEffect runs asynchronously after the browser has painted. useLayoutEffect runs synchronously after React updates the DOM but before the browser paints. Use it when you need to measure the DOM or make visual adjustments that must happen before the user sees anything:

function Tooltip({ children, content }: TooltipProps) {
  const tooltipRef = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState({ top: 0, left: 0 });

  useLayoutEffect(() => {
    // Measure the tooltip BEFORE it's visible to the user
    // useEffect would cause a visible flash of mispositioned content
    if (tooltipRef.current) {
      const rect = tooltipRef.current.getBoundingClientRect();
      setPosition(calculateOptimalPosition(rect));
    }
  }, [content]);

  return (
    <div ref={tooltipRef} style={position}>
      {content}
    </div>
  );
}

If you see a flash of incorrectly positioned content before it snaps into place, the fix is usually useLayoutEffect.

useImperativeHandle — controlled imperative API

useImperativeHandle controls what a parent component sees when it holds a ref to a child. Instead of exposing the raw DOM node, you expose a specific API:

interface VideoPlayerHandle {
  play(): void;
  pause(): void;
  seek(time: number): void;
}

const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
  function VideoPlayer({ src }, ref) {
    const videoRef = useRef<HTMLVideoElement>(null);

    useImperativeHandle(
      ref,
      () => ({
        play() {
          videoRef.current?.play();
        },
        pause() {
          videoRef.current?.pause();
        },
        seek(time: number) {
          if (videoRef.current) {
            videoRef.current.currentTime = time;
          }
        },
      }),
      []
    );

    return <video ref={videoRef} src={src} />;
  }
);

// Parent uses the controlled API, not the raw DOM
function Player() {
  const playerRef = useRef<VideoPlayerHandle>(null);

  return (
    <>
      <VideoPlayer ref={playerRef} src="/video.mp4" />
      <button onClick={() => playerRef.current?.play()}>Play</button>
    </>
  );
}

In React 19, forwardRef is no longer needed — refs are passed as regular props.

Context: The Right Use Cases

Context is the most misused feature in React. It is excellent for truly global, slowly-changing values. It is a performance problem for frequently changing values.

Every component that consumes a context re-renders when the context value changes — regardless of whether it uses the part of the value that changed.

// Problematic: the entire context value is one object
// Any change to theme OR user OR language triggers a re-render
// in every component that reads ANY of these values
const AppContext = createContext({
  theme: 'dark',
  user: null,
  language: 'en',
  notifications: [],
});

// Better: separate contexts for separate concerns
const ThemeContext = createContext<Theme>('dark');
const UserContext = createContext<User | null>(null);
const NotificationContext = createContext<Notification[]>([]);

Separate contexts mean a component that only needs the theme doesn’t re-render when the notification list updates.

Context with useReducer — a lightweight state manager

For small applications or feature-level state that doesn’t warrant a library like Zustand or Redux, context + useReducer is a solid pattern:

const TicketStateContext = createContext<TicketState | null>(null);
const TicketDispatchContext =
  createContext<React.Dispatch<TicketAction> | null>(null);

function TicketProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(ticketReducer, initialState);

  return (
    <TicketStateContext.Provider value={state}>
      <TicketDispatchContext.Provider value={dispatch}>
        {children}
      </TicketDispatchContext.Provider>
    </TicketStateContext.Provider>
  );
}

// Separate hooks — components that only dispatch don't re-render on state changes
function useTicketState() {
  const context = useContext(TicketStateContext);
  if (!context)
    throw new Error('useTicketState must be used within TicketProvider');
  return context;
}

function useTicketDispatch() {
  const context = useContext(TicketDispatchContext);
  if (!context)
    throw new Error('useTicketDispatch must be used within TicketProvider');
  return context;
}

Separating state and dispatch into two contexts means components that only dispatch actions — like a submit button — don’t re-render when state changes. Only components that read state re-render on state changes.

Custom Hooks: Composing Behaviour

Custom hooks are the primary composability mechanism in React. They are not just “extracted useEffect calls” — they are a way to encapsulate entire behaviours, including state, effects, and refs, behind a clean API.

// Encapsulates all the complexity of managing a WebSocket connection
function useWebSocket<T>(url: string) {
  const [state, setState] = useState<{
    status: 'connecting' | 'connected' | 'disconnected' | 'error';
    lastMessage: T | null;
    error: string | null;
  }>({
    status: 'connecting',
    lastMessage: null,
    error: null,
  });

  const socketRef = useRef<WebSocket | null>(null);
  const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
    null
  );

  useEffect(() => {
    function connect() {
      const socket = new WebSocket(url);
      socketRef.current = socket;

      socket.onopen = () => {
        setState((s) => ({ ...s, status: 'connected', error: null }));
      };

      socket.onmessage = (event) => {
        try {
          const data: T = JSON.parse(event.data);
          setState((s) => ({ ...s, lastMessage: data }));
        } catch {
          setState((s) => ({ ...s, error: 'Failed to parse message' }));
        }
      };

      socket.onclose = () => {
        setState((s) => ({ ...s, status: 'disconnected' }));
        // Auto-reconnect after 3 seconds
        reconnectTimeoutRef.current = setTimeout(connect, 3000);
      };

      socket.onerror = () => {
        setState((s) => ({
          ...s,
          status: 'error',
          error: 'Connection failed',
        }));
      };
    }

    connect();

    return () => {
      if (reconnectTimeoutRef.current) {
        clearTimeout(reconnectTimeoutRef.current);
      }
      socketRef.current?.close();
    };
  }, [url]);

  const send = useCallback((data: unknown) => {
    if (socketRef.current?.readyState === WebSocket.OPEN) {
      socketRef.current.send(JSON.stringify(data));
    }
  }, []);

  return { ...state, send };
}

The component using this hook has no idea there’s a WebSocket involved — it just knows there’s a status, a last message, and a send function. The complexity is fully encapsulated.

Concurrent React: The Features That Change the Model

React 18’s concurrent features are the biggest architectural shift since hooks. Most developers are using React 18 without using any concurrent features, which is like having a faster processor but running it in power-saving mode.

useTransition — deferring non-urgent updates

useTransition marks a state update as non-urgent. React can interrupt it to handle more urgent updates — user input, for instance:

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<Result[]>([]);
  const [isPending, startTransition] = useTransition();

  function handleSearch(value: string) {
    // Urgent: update the input immediately
    setQuery(value);

    // Non-urgent: the results list update can be deferred
    startTransition(() => {
      setResults(searchLocalData(value));
    });
  }

  return (
    <>
      <input value={query} onChange={(e) => handleSearch(e.target.value)} />
      {isPending ? (
        <p>Updating results...</p>
      ) : (
        <ResultsList results={results} />
      )}
    </>
  );
}

While the transition is pending, React keeps the old results visible. The input stays responsive — you can keep typing without waiting for the result update. When React has capacity, it applies the transition.

This is the tool for making search, filtering, and tab switching feel instant even when the update is expensive.

useDeferredValue — deferring a value, not an update

useDeferredValue is similar to useTransition but for deferring a value you receive from outside — a prop, for instance:

function ResultsList({ query }: { query: string }) {
  // Defer the query — React may keep showing old results
  // while it works on the new query in the background
  const deferredQuery = useDeferredValue(query);

  const isStale = query !== deferredQuery;

  const results = useMemo(
    () => expensiveSearch(deferredQuery),
    [deferredQuery]
  );

  return (
    <div style={{ opacity: isStale ? 0.5 : 1 }}>
      {results.map((r) => (
        <ResultItem key={r.id} result={r} />
      ))}
    </div>
  );
}

The opacity fade while isStale is true gives the user a visual signal that new results are coming, without the interface freezing.

Suspense — what it actually is

Suspense is widely misunderstood as “the loading state thing.” It is the mechanism by which React coordinates async work — not just data fetching but any operation that isn’t ready yet.

A component “suspends” by throwing a Promise. React catches it, shows the nearest Suspense boundary’s fallback, and waits for the Promise to resolve before trying to render the component again.

function TicketDetail({ id }: { id: string }) {
  // This component suspends while loading — no loading state needed here
  const ticket = use(ticketResource.read(id)); // Suspending read

  return <div>{ticket.subject}</div>;
}

function App() {
  return (
    <ErrorBoundary fallback={<ErrorPage />}>
      <Suspense fallback={<TicketSkeleton />}>
        <TicketDetail id="t-001" />
      </Suspense>
    </ErrorBoundary>
  );
}

The component declares what it needs. React handles the coordination. No if (loading) return <Spinner /> scattered through component bodies.

Suspense works natively with React 18’s data fetching integration, with React Query, and with Server Components in frameworks like Next.js.

The use hook — React 19

React 19 introduced the use hook. It reads the value of a Promise or Context inside a component — and it can suspend if the Promise isn’t resolved yet:

import { use } from 'react';

function TicketDetail({ ticketPromise }: { ticketPromise: Promise<Ticket> }) {
  // Suspends until the promise resolves
  const ticket = use(ticketPromise);

  return <h1>{ticket.subject}</h1>;
}

// The promise is created outside the component — in the parent or route loader
function TicketPage({ id }: { id: string }) {
  const ticketPromise = fetchTicket(id); // Not awaited — passed as a promise

  return (
    <Suspense fallback={<Skeleton />}>
      <TicketDetail ticketPromise={ticketPromise} />
    </Suspense>
  );
}

Unlike useEffect, use can be called conditionally. Unlike async/await, it works within React’s rendering model.

React 19: What Actually Changed

React 19 is a significant release. Beyond use, several changes affect everyday patterns.

Actions — the form handling story

React 19 introduced Actions — functions that handle form submissions and async mutations. Combined with new hooks, they simplify a pattern that previously required significant boilerplate:

// React 19: native form action
async function updateTicketAction(formData: FormData) {
  'use server'; // In a framework like Next.js
  const id = formData.get('id') as string;
  const status = formData.get('status') as TicketStatus;
  await updateTicket(id, { status });
  revalidatePath('/tickets');
}

function TicketStatusForm({ ticket }: { ticket: Ticket }) {
  return (
    <form action={updateTicketAction}>
      <input type="hidden" name="id" value={ticket.id} />
      <select name="status" defaultValue={ticket.status}>
        <option value="open">Open</option>
        <option value="resolved">Resolved</option>
      </select>
      <button type="submit">Update</button>
    </form>
  );
}

useActionState — pending and error state for actions

import { useActionState } from 'react';

async function createTicketAction(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  try {
    const ticket = await createTicket({
      subject: formData.get('subject') as string,
      priority: formData.get('priority') as Priority,
    });
    return { success: true, ticketId: ticket.id, error: null };
  } catch (error) {
    return { success: false, ticketId: null, error: 'Failed to create ticket' };
  }
}

function CreateTicketForm() {
  const [state, formAction, isPending] = useActionState(createTicketAction, {
    success: false,
    ticketId: null,
    error: null,
  });

  return (
    <form action={formAction}>
      <input name="subject" required />
      <select name="priority">
        <option value="low">Low</option>
        <option value="high">High</option>
      </select>
      {state.error && <p role="alert">{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Creating...' : 'Create Ticket'}
      </button>
    </form>
  );
}

useOptimistic — first-class optimistic updates

import { useOptimistic } from 'react';

function TicketList({ tickets }: { tickets: Ticket[] }) {
  const [optimisticTickets, addOptimisticTicket] = useOptimistic(
    tickets,
    (currentTickets, newTicket: Ticket) => [newTicket, ...currentTickets]
  );

  async function handleCreate(formData: FormData) {
    const optimisticTicket: Ticket = {
      id: `temp-${Date.now()}`,
      subject: formData.get('subject') as string,
      status: 'open',
      // ...other fields
    };

    addOptimisticTicket(optimisticTicket);
    await createTicket(formData); // Real server call
    // React automatically reverts to server state when this resolves
  }

  return (
    <>
      <form action={handleCreate}>
        <input name="subject" />
        <button type="submit">Create</button>
      </form>
      {optimisticTickets.map((ticket) => (
        <TicketCard key={ticket.id} ticket={ticket} />
      ))}
    </>
  );
}

Ref as a prop — no more forwardRef

In React 19, refs are passed as regular props. forwardRef is no longer needed:

// React 18 — required forwardRef
const Input = forwardRef<HTMLInputElement, InputProps>(
  function Input(props, ref) {
    return <input {...props} ref={ref} />;
  }
);

// React 19 — ref is just a prop
function Input({
  ref,
  ...props
}: InputProps & { ref?: React.Ref<HTMLInputElement> }) {
  return <input {...props} ref={ref} />;
}

Document metadata — no more Helmet

React 19 supports rendering <title>, <meta>, and <link> tags directly in components. React hoists them to the document head automatically:

function TicketDetailPage({ ticket }: { ticket: Ticket }) {
  return (
    <>
      <title>{ticket.subject} — Support Tickets</title>
      <meta name="description" content={`Ticket ${ticket.id}`} />
      <div>
        <h1>{ticket.subject}</h1>
      </div>
    </>
  );
}

No react-helmet or next/head required. React deduplicates metadata from multiple components, with the deepest component winning.

React Router v7: The Remixed Router

React Router v7 merged with Remix. If you’re familiar with React Router v6, the core concepts carry over. The new features are substantial.

File-based routing and route modules

React Router v7 supports both programmatic routing and file-based routing (when using the framework mode):

// routes.ts — programmatic route definition
import {
  type RouteConfig,
  index,
  route,
  layout,
  prefix,
} from '@react-router/dev/routes';

export default [
  index('routes/home.tsx'),
  layout('routes/layout.tsx', [
    route('tickets', 'routes/tickets/index.tsx'),
    route('tickets/:id', 'routes/tickets/detail.tsx'),
    ...prefix('admin', [route('users', 'routes/admin/users.tsx')]),
  ]),
] satisfies RouteConfig;

Loaders — data fetching at the route level

Loaders run before the route renders. The component receives the data immediately — no loading state needed in the component:

// routes/tickets/detail.tsx
import type { Route } from './+types/detail';
import { ticketService } from '~/services/ticket.service';

// Loader runs on the server (or in SPA mode, in the browser)
export async function loader({ params }: Route.LoaderArgs) {
  const ticket = await ticketService.getTicket(params.id);

  if (!ticket) {
    throw new Response('Not Found', { status: 404 });
  }

  return { ticket };
}

// Component receives typed data from the loader
export default function TicketDetail({ loaderData }: Route.ComponentProps) {
  const { ticket } = loaderData; // Fully typed from the loader return

  return (
    <div>
      <h1>{ticket.subject}</h1>
      <StatusBadge status={ticket.status} />
    </div>
  );
}

Actions — mutations at the route level

Route actions handle form submissions and mutations:

// routes/tickets/detail.tsx continued
import { Form, redirect } from 'react-router';

export async function action({ request, params }: Route.ActionArgs) {
  const formData = await request.formData();
  const intent = formData.get('intent');

  if (intent === 'update-status') {
    const status = formData.get('status') as TicketStatus;
    await ticketService.updateStatus(params.id, status);
    return { success: true };
  }

  if (intent === 'close') {
    await ticketService.closeTicket(params.id);
    return redirect('/tickets');
  }

  return { error: 'Unknown intent' };
}

export default function TicketDetail({
  loaderData,
  actionData,
}: Route.ComponentProps) {
  const { ticket } = loaderData;

  return (
    <div>
      <h1>{ticket.subject}</h1>

      <Form method="post">
        <input type="hidden" name="intent" value="update-status" />
        <select name="status" defaultValue={ticket.status}>
          <option value="open">Open</option>
          <option value="resolved">Resolved</option>
        </select>
        <button type="submit">Update Status</button>
      </Form>

      {actionData?.error && <p>{actionData.error}</p>}
    </div>
  );
}

useNavigation — pending UI state

import { useNavigation, Form } from 'react-router';

function TicketForm() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';
  const isLoading = navigation.state === 'loading';

  return (
    <Form method="post">
      <input name="subject" required />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Saving...' : 'Save'}
      </button>
      {isLoading && <LoadingBar />}
    </Form>
  );
}

useFetcher — mutations without navigation

import { useFetcher } from 'react-router';

function QuickStatusUpdate({ ticket }: { ticket: Ticket }) {
  const fetcher = useFetcher();
  const isUpdating = fetcher.state !== 'idle';

  return (
    <fetcher.Form method="post" action={`/tickets/${ticket.id}`}>
      <input type="hidden" name="intent" value="update-status" />
      <select
        name="status"
        defaultValue={ticket.status}
        onChange={(e) => {
          // Auto-submit on change — no button needed
          fetcher.submit(
            { intent: 'update-status', status: e.target.value },
            { method: 'post', action: `/tickets/${ticket.id}` }
          );
        }}
      >
        <option value="open">Open</option>
        <option value="resolved">Resolved</option>
      </select>
      {isUpdating && <Spinner />}
    </fetcher.Form>
  );
}

useFetcher is the tool for in-place mutations — liking a post, toggling a status, deleting an item — where you don’t want the URL to change.

TanStack Query v5: Server State Done Right

TanStack Query (formerly React Query) is the most important React library I’ve added to projects in the last four years. It solves server state — the state that lives on the server and is mirrored in the client — in a way that eliminates an enormous amount of loading state management, caching logic, and synchronisation code.

The mental model shift

Without TanStack Query, a typical data-fetching component looks like this:

function TicketList() {
  const [tickets, setTickets] = useState<Ticket[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    setLoading(true);
    fetchTickets()
      .then((data) => {
        setTickets(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <List tickets={tickets} />;
}

With TanStack Query:

function TicketList() {
  const {
    data: tickets,
    isLoading,
    error,
  } = useQuery({
    queryKey: ['tickets'],
    queryFn: fetchTickets,
  });

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage error={error.message} />;
  return <List tickets={tickets!} />;
}

Identical behaviour. But TanStack Query also gives you: automatic caching, background refetching, stale-while-revalidate, retry on failure, deduplication of identical requests, and cache invalidation. All without any additional configuration.

Query keys — the design decision that matters most

Query keys are the identity of a cached value. Same key — same cache entry. Different key — different cache entry.

// These are all different cache entries
useQuery({ queryKey: ['tickets'], queryFn: fetchAllTickets });
useQuery({
  queryKey: ['tickets', { status: 'open' }],
  queryFn: fetchOpenTickets,
});
useQuery({
  queryKey: ['tickets', 'detail', 'ticket-001'],
  queryFn: () => fetchTicket('ticket-001'),
});

A well-designed query key structure makes cache invalidation intuitive:

// Query key factory — keeps keys consistent and prevents typos
const ticketKeys = {
  all: ['tickets'] as const,
  lists: () => [...ticketKeys.all, 'list'] as const,
  list: (filters: TicketFilters) => [...ticketKeys.lists(), filters] as const,
  details: () => [...ticketKeys.all, 'detail'] as const,
  detail: (id: string) => [...ticketKeys.details(), id] as const,
};

// Usage
useQuery({
  queryKey: ticketKeys.detail('ticket-001'),
  queryFn: () => fetchTicket('ticket-001'),
});

// Invalidation — invalidates everything under ['tickets', 'detail']
queryClient.invalidateQueries({ queryKey: ticketKeys.details() });

// Invalidates the entire tickets cache
queryClient.invalidateQueries({ queryKey: ticketKeys.all });

useMutation — the right tool for mutations

import { useMutation, useQueryClient } from '@tanstack/react-query';

function TicketStatusButton({ ticket }: { ticket: Ticket }) {
  const queryClient = useQueryClient();

  const updateStatus = useMutation({
    mutationFn: ({ id, status }: { id: string; status: TicketStatus }) =>
      ticketService.updateStatus(id, status),

    // Optimistic update
    onMutate: async ({ id, status }) => {
      // Cancel in-flight queries for this ticket
      await queryClient.cancelQueries({ queryKey: ticketKeys.detail(id) });

      // Snapshot previous value for rollback
      const previousTicket = queryClient.getQueryData(ticketKeys.detail(id));

      // Optimistically update the cache
      queryClient.setQueryData(ticketKeys.detail(id), (old: Ticket) => ({
        ...old,
        status,
      }));

      return { previousTicket };
    },

    // Rollback on error
    onError: (error, { id }, context) => {
      if (context?.previousTicket) {
        queryClient.setQueryData(ticketKeys.detail(id), context.previousTicket);
      }
    },

    // Refetch after success or error to ensure consistency
    onSettled: (data, error, { id }) => {
      queryClient.invalidateQueries({ queryKey: ticketKeys.detail(id) });
    },
  });

  return (
    <button
      onClick={() => updateStatus.mutate({ id: ticket.id, status: 'resolved' })}
      disabled={updateStatus.isPending}
    >
      {updateStatus.isPending ? 'Updating...' : 'Mark Resolved'}
    </button>
  );
}

Prefetching and the query client

Prefetch data before the user navigates to it:

function TicketListItem({ ticket }: { ticket: Ticket }) {
  const queryClient = useQueryClient();

  return (
    <div
      // Prefetch the detail on hover
      onMouseEnter={() => {
        queryClient.prefetchQuery({
          queryKey: ticketKeys.detail(ticket.id),
          queryFn: () => fetchTicket(ticket.id),
          staleTime: 60 * 1000, // Don't prefetch if cached within 1 minute
        });
      }}
    >
      <Link to={`/tickets/${ticket.id}`}>{ticket.subject}</Link>
    </div>
  );
}

When the user clicks, the data is already in the cache. The detail page appears instantly.

Infinite queries

function TicketFeed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ticketKeys.lists(),
      queryFn: ({ pageParam }) =>
        fetchTicketsPage({ page: pageParam, pageSize: 20 }),
      initialPageParam: 1,
      getNextPageParam: (lastPage, allPages) =>
        lastPage.hasNextPage ? allPages.length + 1 : undefined,
    });

  const tickets = data?.pages.flatMap((page) => page.items) ?? [];

  return (
    <>
      {tickets.map((ticket) => (
        <TicketCard key={ticket.id} ticket={ticket} />
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </>
  );
}

Query invalidation strategies

// After creating a ticket — invalidate the list, not the details
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });

// After updating a ticket — update the cache directly and invalidate
queryClient.setQueryData(ticketKeys.detail(id), updatedTicket);
queryClient.invalidateQueries({ queryKey: ticketKeys.lists() });

// Invalidate all ticket queries — nuclear option, useful after bulk operations
queryClient.invalidateQueries({ queryKey: ticketKeys.all });

// Remove a cache entry entirely — next access will trigger a fresh fetch
queryClient.removeQueries({ queryKey: ticketKeys.detail(id) });

Performance Patterns You Won’t Find in Tutorials

Windowing large lists

Rendering thousands of list items is expensive. Virtual lists render only the items in the viewport:

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualTicketList({ tickets }: { tickets: Ticket[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: tickets.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 80, // Estimated row height in px
    overscan: 5, // Render 5 extra items above/below viewport
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflowY: 'auto' }}>
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <TicketCard ticket={tickets[virtualItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Code splitting with lazy and Suspense

import { lazy, Suspense } from 'react';

// The analytics dashboard is heavy — only load it when needed
const AnalyticsDashboard = lazy(() => import('./analytics/AnalyticsDashboard'));

function App() {
  return (
    <Routes>
      <Route path="/tickets" element={<TicketList />} />
      <Route
        path="/analytics"
        element={
          <Suspense fallback={<PageSkeleton />}>
            <AnalyticsDashboard />
          </Suspense>
        }
      />
    </Routes>
  );
}

Component composition over configuration

Deep prop drilling and boolean prop explosion (showHeader, showFooter, showActions, isCompact, isLoading) are signs that a component is trying to be too many things. Composition solves this:

// Prop explosion — hard to use, hard to test
<TicketCard
  ticket={ticket}
  showHeader={true}
  showActions={true}
  showPriority={false}
  isCompact={false}
  onEdit={handleEdit}
  onClose={handleClose}
/>

// Composition — clear, flexible, each piece is independently controlled
<TicketCard ticket={ticket}>
  <TicketCard.Header />
  <TicketCard.Priority />
  <TicketCard.Actions>
    <EditButton onClick={handleEdit} />
    <CloseButton onClick={handleClose} />
  </TicketCard.Actions>
</TicketCard>

Implemented with compound components:

function TicketCard({
  ticket,
  children,
}: {
  ticket: Ticket;
  children: React.ReactNode;
}) {
  return (
    <TicketContext.Provider value={ticket}>
      <div className="ticket-card">{children}</div>
    </TicketContext.Provider>
  );
}

TicketCard.Header = function TicketCardHeader() {
  const ticket = useContext(TicketContext);
  return <h3>{ticket.subject}</h3>;
};

TicketCard.Priority = function TicketCardPriority() {
  const ticket = useContext(TicketContext);
  return <PriorityBadge priority={ticket.priority} />;
};

TicketCard.Actions = function TicketCardActions({
  children,
}: {
  children: React.ReactNode;
}) {
  return <div className="ticket-card__actions">{children}</div>;
};

Error Boundaries: Graceful Failure

Error boundaries are class components that catch errors in their subtree. React hasn’t provided a hook equivalent yet — you still need a class for this:

class ErrorBoundary extends React.Component<
  { fallback: React.ReactNode; children: React.ReactNode },
  { hasError: boolean; error: Error | null }
> {
  constructor(props: any) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    errorReportingService.report(error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

// Usage — wrapping individual sections, not the whole app
function Dashboard() {
  return (
    <div>
      <ErrorBoundary fallback={<MetricError />}>
        <MetricsWidget />
      </ErrorBoundary>
      <ErrorBoundary fallback={<ChartError />}>
        <RevenueChart />
      </ErrorBoundary>
    </div>
  );
}

Granular error boundaries mean a failing widget doesn’t take down the entire page. Use react-error-boundary from npm if you want a hook-friendly wrapper and reset capabilities.

What Separates Good React Developers from Great Ones

After years of code reviews, pair programming, and mentoring, the patterns I consistently see in the strongest React developers:

They understand the render cycle. They can look at a component and predict when it will re-render and why. They know when React.memo, useMemo, and useCallback are needed — and when they’re not.

They reach for the right tool for state. Local state for local concerns. Context for slowly-changing global values. TanStack Query for server state. Zustand or Redux Toolkit for complex shared client state. They don’t default to one tool for everything.

They write custom hooks for everything complex. A component that contains both business logic and rendering is a component that’s doing too much. The logic belongs in a hook — testable, reusable, readable in isolation.

They take useEffect seriously. They understand the synchronisation model. They know the stale closure problem. They have the exhaustive-deps linting rule enabled and they listen to it.

They treat React 18+ concurrent features as available tools. useTransition, useDeferredValue, Suspense — they know what each one does and they reach for them when the problem fits.

Conclusion

React has changed more between v15 and v19 than most developers realise. The class component patterns, the lifecycle methods, the old context API, the componentDidMount for data fetching — all of it is either deprecated or superseded by better patterns. The tutorials that were written during that era are still widely recommended and still widely followed.

The React now is hooks, concurrent features, Server Components in framework contexts, TanStack Query for server state, and React Router v7 with loaders and actions for routing with data. The mental model is synchronisation, not lifecycle. The architecture is composition, not inheritance.

The developers I’ve seen grow fastest in React are the ones who spent time understanding the reconciler — why elements have identity, why keys matter, why re-renders happen. Everything else is pattern recognition on top of that foundation. Once the model is clear, the patterns make sense instead of feeling like rules to memorise.

Learn the model. The patterns follow.

Resources