Advanced React concepts are the patterns and APIs you need once the basics (components, props, state and effects) are second nature: how React decides what to keep or recreate, sharing logic with custom hooks, rendering outside the component tree with portals, recovering from crashes with error boundaries, splitting code with Suspense, keeping the UI responsive with transitions, and the newer React 19 features such as Actions, optimistic updates, <Activity>, the React Compiler and Server Components.
This guide covers 13 of them with short examples. I built every interactive example as a small React 19.3 app with Vite and checked its behaviour in a headless browser, so the results described are from real runs. If you need a refresher first, read the React core concepts guide.
Quick Overview
| Concept | What it solves | Since |
|---|---|---|
| Reconciliation and keys | Keeping the right state on the right element | Always |
| Context | Passing data deep without prop drilling | 16.3 (current API) |
| Custom hooks | Reusing stateful logic | 16.8 |
| Render props | Letting the caller decide what to render | Pattern |
| Higher-order components | Wrapping components with extra behaviour | Pattern |
| Portals | Rendering modals and tooltips outside the parent DOM | 16 |
| Error boundaries | Showing a fallback instead of a blank page | 16 |
| lazy and Suspense | Loading code only when it is needed | 16.6 |
| Transitions | Keeping input responsive during heavy updates | 18 |
| Actions and useOptimistic | Forms, pending states and instant feedback | 19 |
| <Activity> | Hiding UI without losing its state | 19.2 |
| React Compiler | Automatic memoization | 1.0 in Oct 2025 |
| Server Components | Rendering on the server with zero client JavaScript | 19 (via frameworks) |
1. Reconciliation and Keys
When state changes, React compares the new element tree with the previous one and keeps any component that has the same type in the same position, along with its state. In lists, the key tells React which item is which. Using the array index as the key breaks this when items are removed or reordered:
import { useState } from 'react';
const initial = [{ id: 'a', name: 'Ada' }, { id: 'b', name: 'Alan' }, { id: 'c', name: 'Grace' }];
export function KeysDemo({ useIndexKeys }) {
const [people, setPeople] = useState(initial);
return (
<div className={useIndexKeys ? 'keys-index' : 'keys-id'}>
<button onClick={() => setPeople(people.slice(1))}>Remove first</button>
<ul>
{people.map((person, index) => (
<li key={useIndexKeys ? index : person.id}>
{person.name}: <input placeholder="note" />
</li>
))}
</ul>
</div>
);
}I typed a note into Ada’s and Alan’s inputs, then removed the first person:
| Keys | Alan’s input after removing Ada | Grace’s input |
|---|---|---|
key={index} | “note for Ada” (wrong) | “note for Alan” (wrong) |
key={person.id} | “note for Alan” | empty |
With index keys, React saw “row 0 and row 1 still exist, row 2 was removed”, so the inputs’ state stayed in place while the names shifted. Use a stable ID from your data. The same rule explains a second surprise: {tab === 'a' ? <Counter label="A" /> : <Counter label="B" />} keeps the same counter state when you switch tabs, because the type and position don’t change. I hit exactly this while building the Activity demo below. Render them as separate conditionals or give them different keys to reset state (see Preserving and Resetting State on react.dev).
2. Context
Context passes a value to any component below a provider, without threading props through every level. It suits data that many components need: theme, locale, the signed-in user. In React 19 you render the context itself as the provider and can read it with use():
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('dark');
return (
<ThemeContext value={theme}> {/* React 19; earlier: <ThemeContext.Provider> */}
<Toolbar />
</ThemeContext>
);
}
function Button() {
const theme = use(ThemeContext); // or useContext(ThemeContext)
return <button className={theme}>Save</button>;
}Two things to keep in mind: every component that reads a context re-renders when its value changes, so split rarely-changing and frequently-changing data into separate contexts; and context is not a state manager by itself. For a complete example, see my guide on adding dark mode in React, which uses a theme context, and for larger shared state, global state with React Redux. The old childContextTypes / getChildContext API shown in the 2023 version of this post is legacy and was removed in React 19.
3. Custom Hooks
A custom hook is a function starting with use that calls other hooks. It is the main way to reuse stateful logic. This one subscribes to the browser’s online status with useSyncExternalStore, the hook designed for reading external sources safely:
import { useSyncExternalStore } from 'react';
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
export function useOnlineStatus() {
return useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
}import { useOnlineStatus } from './useOnlineStatus';
export function StatusBar() {
const online = useOnlineStatus();
return <p id="online">{online ? 'Online' : 'Offline: changes will sync later'}</p>;
}When I switched the browser to offline mode, the text changed to “Offline: changes will sync later”, and back to “Online” when I restored the connection. For data fetching, the same idea applies; see how to fetch data with React hooks.
4. Render Props
A render prop is a function prop that a component calls to decide what to render, so the component owns the behaviour and the caller owns the markup. children as a function is the most common form:
import { useState } from 'react';
// The component owns the behaviour; the caller decides what to render
function Hoverable({ children }) {
const [hovered, setHovered] = useState(false);
return (
<div onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)}>
{children(hovered)}
</div>
);
}
export function RenderPropDemo() {
return (
<Hoverable>
{(hovered) => <p id="hover">{hovered ? 'Hovering!' : 'Hover over me'}</p>}
</Hoverable>
);
}The text changed from “Hover over me” to “Hovering!” on hover. Custom hooks have replaced most render props (a useHover() hook would do the same), but you’ll still see the pattern in libraries for lists, virtualized tables and animation, where the library needs to call your rendering code with its own values.
5. Higher-Order Components
A higher-order component is a function that takes a component and returns a new one with extra behaviour, such as withAuthenticationRequired(Page). They are less common since hooks, but still used for wrapping and guarding whole components. I covered them in detail, including React 19’s ref changes and a state-reset pitfall, in higher-order components in React.
6. Portals
createPortal renders children into a different DOM node, usually document.body, so modals, tooltips and dropdowns aren’t clipped by a parent’s overflow: hidden or z-index. In React, they still behave as children of the component that rendered them:
import { useState } from 'react';
import { createPortal } from 'react-dom';
function Modal({ onClose, children }) {
return createPortal(
<div className="backdrop" role="dialog" aria-modal="true">
<div className="modal">
{children}
<button onClick={onClose}>Close</button>
</div>
</div>,
document.body, // rendered here in the DOM...
);
}
export function PortalDemo() {
const [open, setOpen] = useState(false);
const [clicks, setClicks] = useState(0);
return (
// ...but events still bubble up the React tree to this div
<div onClick={() => setClicks((c) => c + 1)} style={{ overflow: 'hidden' }}>
<button onClick={() => setOpen(true)}>Open modal</button>
<p id="portal-clicks">Clicks seen by parent: {clicks}</p>
{open && <Modal onClose={() => setOpen(false)}>Hello from a portal</Modal>}
</div>
);
}In my test, the dialog’s DOM parent was <body>, yet clicks inside the modal still reached the parent div‘s onClick: after clicking “Open modal” and then clicking inside the modal, the parent had counted 2 clicks. Context also flows into portals. Remember to handle focus and the Escape key for real dialogs, or use the native <dialog> element, which now covers many of these needs itself.
7. Error Boundaries
If a component throws while rendering, React unmounts the whole tree, leaving a blank page. An error boundary catches errors in its children and shows a fallback instead. They are still written as class components (or with the react-error-boundary package):
import { Component, useState } from 'react';
export class ErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error }; // show the fallback on the next render
}
componentDidCatch(error, info) {
console.log('logged:', error.message); // send to your error tracker here
}
render() {
if (this.state.error) {
return (
<div role="alert">
<p>Something went wrong: {this.state.error.message}</p>
<button onClick={() => this.setState({ error: null })}>Try again</button>
</div>
);
}
return this.props.children;
}
}
function Profile({ user }) {
return <p>Name: {user.name.toUpperCase()}</p>; // crashes if name is missing
}
export function ErrorDemo() {
const [user, setUser] = useState({ name: 'Ada' });
return (
<div>
<button onClick={() => setUser({})}>Load broken user</button>
<button onClick={() => setUser({ name: 'Grace' })}>Load good user</button>
<ErrorBoundary>
<Profile user={user} />
</ErrorBoundary>
<p>The rest of the page still works.</p>
</div>
);
}Loading the broken user replaced only the profile with “Something went wrong: Cannot read properties of undefined (reading ‘toUpperCase’)”, while the rest of the page kept working; componentDidCatch logged the error. After loading a good user and clicking “Try again”, the profile rendered again. Error boundaries don’t catch errors in event handlers, timers or async code; handle those with try...catch. Place boundaries around independent sections (a sidebar, a widget, each route) rather than only at the top.
8. Code Splitting with lazy and Suspense
import { lazy, Suspense, useState } from 'react';
const Chart = lazy(() => import('./Chart')); // separate file, loaded on demand
export function LazyDemo() {
const [show, setShow] = useState(false);
return (
<div>
<button onClick={() => setShow(true)}>Show chart</button>
{show && (
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
)}
</div>
);
}Vite put Chart in its own file (Chart-[hash].js). The browser didn’t request it on page load; after clicking “Show chart” it showed “Loading chart…” while the file downloaded (I delayed it by 600 ms), then rendered the chart. Split by route or by heavy, rarely-used components such as editors and charts. Suspense also shows fallbacks for data in frameworks and libraries that support it.
9. Transitions: useTransition and useDeferredValue
React normally treats every update as urgent. If an update renders something slow, such as a long filtered list, typing freezes until it finishes. Marking that update as a transition lets React interrupt it to handle new input first:
import { memo, useState, useTransition } from 'react';
const items = Array.from({ length: 5000 }, (_, i) => `Item ${i}`);
function SlowItem({ text }) {
const start = performance.now();
while (performance.now() - start < 1) {} // pretend each row is expensive (1 ms)
return <li>{text}</li>;
}
const SlowList = memo(function SlowList({ filter }) {
const shown = items.filter((item) => item.includes(filter)).slice(0, 200);
return <ul>{shown.map((item) => <SlowItem key={item} text={item} />)}</ul>;
});
export function TransitionDemo({ useTransitionApi }) {
const [text, setText] = useState('');
const [filter, setFilter] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setText(e.target.value); // urgent: update the input now
if (useTransitionApi) {
startTransition(() => setFilter(e.target.value)); // low priority, can be interrupted
} else {
setFilter(e.target.value);
}
}
return (
<div>
<input value={text} onChange={handleChange} placeholder="Filter" />
{isPending && <span>Updating...</span>}
<SlowList filter={filter} />
</div>
);
}I typed “Item 12” with 40 ms between keystrokes and measured each keystroke with the browser’s Event Timing API:
| Version | Slow keystrokes (over 16 ms) | Time until typing and list were done |
|---|---|---|
| Without transition | 7 of 7, about 200 ms each | 1,620 ms |
With startTransition | None (at most 16 ms) | About 430 ms |
Without the transition, every keystroke waited for a full 200-row render. With it, React rendered the input immediately, abandoned outdated list renders, and only finished the list for the final text. useDeferredValue(value) gives the same effect when you don’t control the state update, for example a value coming from props. Transitions help when rendering is the bottleneck; a single long synchronous function still blocks (see how to speed up JavaScript).
10. Actions, useActionState and useOptimistic
React 19 lets you pass an async function to a form’s action. useActionState tracks its pending state and result, and useOptimistic shows the expected result immediately, rolling back automatically if the action fails:
import { useActionState, useOptimistic, useState } from 'react';
// Pretend server call: takes 500 ms, rejects names containing "fail"
async function saveTodo(title) {
await new Promise((r) => setTimeout(r, 500));
if (title.includes('fail')) throw new Error('Server rejected the todo');
return { id: crypto.randomUUID(), title };
}
export function ActionsDemo() {
const [todos, setTodos] = useState([{ id: '1', title: 'Learn React 19' }]);
const [optimisticTodos, addOptimistic] = useOptimistic(todos, (current, title) => [
...current,
{ id: 'temp', title, pending: true },
]);
const [error, submitAction, isPending] = useActionState(async (_prev, formData) => {
const title = formData.get('title');
addOptimistic(title); // shown immediately
try {
const saved = await saveTodo(title);
setTodos((t) => [...t, saved]); // real data replaces the optimistic item
return null;
} catch (err) {
return err.message; // optimistic item disappears automatically
}
}, null);
return (
<form action={submitAction}>
<input name="title" placeholder="New todo" />
<button disabled={isPending}>{isPending ? 'Saving...' : 'Add'}</button>
{error && <p role="alert">{error}</p>}
<ul>
{optimisticTodos.map((t) => (
<li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.title}{t.pending && ' (saving)'}</li>
))}
</ul>
</form>
);
}| Moment | What the user saw |
|---|---|
| 100 ms after adding “Write blog post” | Button “Saving…”, new item shown faded with “(saving)” |
| After the save finished | Button “Add”, item shown normally |
| 100 ms after adding “please fail” | Item shown optimistically with “(saving)” |
| After the server rejected it | Item gone, message “Server rejected the todo” |
One catch: React resets an uncontrolled form after its action finishes, and in my test it cleared the input after the failed save too, so the user lost what they typed. If an action can fail, return the submitted values with the error and pass them back as defaultValue, or keep the input controlled. In frameworks with Server Components, the action can be a server function, so the same form works even before JavaScript loads.
11. Activity: Hide UI Without Losing State
import { Activity, useState } from 'react';
function Counter({ label }) {
const [count, setCount] = useState(0);
return <button onClick={() => setCount((c) => c + 1)}>{label}: {count}</button>;
}
export function ActivityDemo({ useActivity }) {
const [tab, setTab] = useState('a');
return (
<div>
<button onClick={() => setTab('a')}>Tab A</button>
<button onClick={() => setTab('b')}>Tab B</button>
{useActivity ? (
<>
<Activity mode={tab === 'a' ? 'visible' : 'hidden'}><Counter label="A" /></Activity>
<Activity mode={tab === 'b' ? 'visible' : 'hidden'}><Counter label="B" /></Activity>
</>
) : (
<>
{tab === 'a' && <Counter label="A" />}
{tab === 'b' && <Counter label="B" />}
</>
)}
</div>
);
}I clicked counter A three times, switched to tab B and back. With conditional rendering, A was unmounted and came back as 0; inside <Activity mode="hidden"> it came back as 3. A hidden Activity keeps its state and DOM but unmounts its effects, so subscriptions and timers stop while it is hidden. Use it for tabs, sidebars and back navigation, and to pre-render a screen the user is likely to open next.
12. Memoization and the React Compiler
React.memo, useMemo and useCallback skip work when inputs haven’t changed (the transition demo uses memo so the list only re-renders when the filter changes). Adding them by hand is tedious and easy to get wrong. The React Compiler, stable since version 1.0 in October 2025, adds this memoization automatically at build time. It works with React 17 and later, and its lint rules ship in eslint-plugin-react-hooks (see how to configure ESLint for React). In a new project, enable the compiler and only reach for manual memoization when profiling shows a need.
13. Server Components
React Server Components render on the server (at build time or per request) and send only their output to the browser, with none of their code. They can be async and read databases or files directly; interactive parts are marked with 'use client':
// app/posts/page.jsx (Server Component, no JavaScript sent to the browser)
import { db } from '@/lib/db';
import LikeButton from './LikeButton';
export default async function Posts() {
const posts = await db.post.findMany();
return posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<LikeButton id={post.id} /> {/* client component, hydrated in the browser */}
</article>
));
}
// LikeButton.jsx
'use client';
export default function LikeButton({ id }) { /* useState, onClick... */ }You use them through a framework that supports them, such as Next.js, rather than plain React. They are different from server-side rendering, which renders client components to HTML and then hydrates all of them; I compared the approaches in server-side rendering in React.
What to Learn Next
Recent releases keep adding to this list: React 19.2 made <Activity> stable, and React 19.3 stabilized View Transitions and Fragment Refs. If you’re choosing where to go next, routing (React Router tutorial) and data fetching libraries are the most practical next steps for most apps.
Conclusion
Most advanced React comes down to a few ideas: React keeps state by component type, position and key; logic is shared through custom hooks (and occasionally render props or HOCs); portals, error boundaries and Suspense control where and when things render; and transitions, Actions, useOptimistic and <Activity> keep the interface responsive. Add the React Compiler for automatic memoization and Server Components when your framework supports them, and you are using React the way it is designed to be used in 2026.
FAQs
What are advanced React concepts?
Patterns and APIs beyond components, props and basic hooks: reconciliation and keys, context, custom hooks, render props, higher-order components, portals, error boundaries, lazy loading with Suspense, transitions, Actions and useOptimistic, Activity, the React Compiler and Server Components.
Why should I not use the array index as a key in React?
When items are added, removed or reordered, index keys make React match the wrong items, so state such as input values stays at the old position. In my test, removing the first row moved its typed note onto the next person. Use a stable ID instead.
Are render props and higher-order components still used?
Less than before. Custom hooks replaced them for most logic sharing, but both patterns still appear in libraries and for wrapping or guarding whole components.
Do error boundaries catch all errors?
No. They catch errors thrown while rendering, in lifecycle methods and in constructors of their children. Errors in event handlers, timers and async code must be handled with try…catch.
What does useTransition do in React?
It marks a state update as non-urgent so React can interrupt it to handle user input. In my test, typing into a slow filtered list went from about 200 ms per keystroke to under 16 ms.
Do I still need useMemo and useCallback?
Less often. The React Compiler, stable since October 2025, memoizes components and values automatically. Without it, use them where profiling shows expensive re-renders.
What is the difference between Server Components and server-side rendering?
Server-side rendering turns client components into HTML and then hydrates all of them in the browser. Server Components run only on the server and send no JavaScript for themselves; only the client components inside them hydrate.








