Who this is for
If you know some HTML, CSS and JavaScript and want to understand React properly, this post is for you. If you already use React, skip to the later sections on refs, custom hooks and Server Components; they're where most real bugs live.
Every example is taken from code that runs on TheRare's customer dashboard, trimmed so it fits on a screen. Real code is more useful than a to-do app, because every line of it exists for a reason.
1. Components and props
A React component is a function that returns what should appear on screen. Props are its inputs, like arguments to a function.
Here is the start of our gallery's page navigation. It takes the current page and the total, and returns buttons:
export default function Pager({
page,
totalPages,
totalElements,
onPage,
}: {
page: number; // zero-based, as the API returns it
totalPages: number;
totalElements: number;
onPage: (nextPage: number) => void;
}) {
if (totalPages <= 1) return null; // one page: render nothing at all
return (
<button onClick={() => onPage(page + 1)} disabled={page + 1 >= totalPages}>
Next
</button>
);
}Three ideas to notice:
- The HTML-looking syntax is JSX. It compiles to ordinary function calls.
- A component can return null to render nothing. Small bookings with one page of photos show no pager at all.
- The pager doesn't change the page itself. It calls
onPageand lets its parent decide. Data flows down through props; events flow up through callbacks. That one rule explains most React code you'll ever read.
2. State
Props come from the parent. State is data a component owns and can change. When state changes, React calls the component again and updates the screen to match.
function MediaGallery({ media }: { media: MediaAsset[] }) {
const [page, setPage] = useState(0);
const totalPages = Math.max(1, Math.ceil(media.length / PAGE_SIZE));
// ...
}useState(0) gives back the current value and a function to change it. Never assign to page directly; call setPage, so React knows to re-render.
Notice what is not state: totalPages. It can be calculated from media, so we calculate it on every render. Storing it in state as well would give us two copies of the truth that can drift apart. The rule: if you can compute it, don't store it.
3. Lists and keys
To show many items, map an array to elements. Each one needs a key:
{pageItems.map((item) => (
<MediaTile key={item.id} item={item} />
))}The key tells React which element is which between renders. Use a stable id from your data. Using the array index is a classic bug: when a customer deletes the third photo, every photo after it gets a new index, React thinks they're different items, and state like "selected" or "playing" jumps to the wrong tile.
4. Effects, and cleaning up after them
Rendering should only describe the screen. Anything that reaches outside React (timers, network calls, browser APIs, subscriptions) goes in an effect, which runs after the screen updates.
This is LazyMount, the component that stopped our gallery crashing phones (Part 3 tells that story). It watches whether a tile is near the screen, and only then renders the real content:
export default function LazyMount({ children, minHeight = 96, rootMargin = "300px" }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (visible) return;
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin },
);
observer.observe(el);
return () => observer.disconnect(); // cleanup
}, [visible, rootMargin]);
return (
<div ref={ref} style={visible ? undefined : { minHeight }}>
{visible ? children : null}
</div>
);
}Two things beginners miss:
- The dependency array (
[visible, rootMargin]) says when the effect should run again. Leave something out and the effect keeps using an old value; put in something that changes every render and it runs forever. - The cleanup function. The function you return runs before the effect re-runs and when the component disappears. Without
observer.disconnect(), every gallery page you visited would leave observers running in the background. In development, React deliberately runs effects twice to flush out missing cleanups.
minHeight is a small detail with a big effect: it reserves the tile's space before the content exists, so the page doesn't jump as tiles appear.
5. Composition with children
LazyMount doesn't know what it's wrapping. Whatever you put between its tags arrives as the children prop:
<LazyMount key={asset.id} minHeight={220}>
<VideoRow asset={asset} />
</LazyMount>This is composition: small components that each do one job, combined by nesting. We use LazyMount around photo tiles, video rows and raw-footage clips without changing any of them.
6. Refs: values that shouldn't cause a re-render
useRef gives you a box whose .current value survives re-renders but doesn't trigger one when it changes. It has two uses.
Pointing at a DOM element, like ref={ref} above, so the effect can observe the real <div>.
Remembering a value without re-rendering. This one solves the most confusing bug in React: the stale closure. Look at our infinite-scroll trigger:
export function InfiniteScrollSentinel({ onReach, disabled }) {
const ref = useRef<HTMLDivElement>(null);
const onReachRef = useRef(onReach);
onReachRef.current = onReach; // always the latest callback
useEffect(() => {
if (disabled) return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) onReachRef.current();
});
observer.observe(ref.current!);
return () => observer.disconnect();
}, [disabled]); // note: onReach is not here
return <div ref={ref} aria-hidden className="h-px w-full" />;
}The observer's callback is created once. If it called onReach directly, it would call the version from the first render forever, one that knows only the first page of photos. Putting onReach in the dependency array would fix that, but would tear down and recreate the observer on every render. Reading the latest function through a ref gives us both: one observer, always the current callback.
Refs are also good guards. Our shared-gallery page keeps an inFlight ref per section, so two scroll events in quick succession can't fetch the same page twice:
if (!gallery || inFlight.current[source]) return;
inFlight.current[source] = true;State would be wrong here: the second event can arrive before React re-renders with the new state, so it would still see false.
7. Custom hooks
A custom hook is a function starting with use that bundles other hooks into something reusable. This is useOnScreen. It answers one question: is this element on screen, and is this browser tab the one being looked at?
export function useOnScreen<T extends Element>(): [RefObject<T>, boolean] {
const ref = useRef<T>(null);
const [intersecting, setIntersecting] = useState(false);
const [tabVisible, setTabVisible] = useState(document.visibilityState === "visible");
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setIntersecting(entry.isIntersecting),
{ threshold: 0.1 },
);
observer.observe(ref.current!);
return () => observer.disconnect();
}, []);
useEffect(() => {
const onChange = () => setTabVisible(document.visibilityState === "visible");
document.addEventListener("visibilitychange", onChange);
return () => document.removeEventListener("visibilitychange", onChange);
}, []);
return [ref, intersecting && tabVisible];
}Why it exists: each booking card on the dashboard has a message thread that checks for new messages every 15 to 20 seconds. A customer with five bookings had five threads polling at once, even the ones scrolled out of view, even with the tab in the background. With this hook, a thread polls only while someone can actually see it:
const [threadRef, isVisible] = useOnScreen<HTMLDivElement>();
useEffect(() => {
if (!isVisible) return;
const timer = setInterval(loadMessages, 15_000);
return () => clearInterval(timer);
}, [isVisible]);Custom hooks are how you stop repeating yourself in React. If you've written the same useState + useEffect pair twice, it probably wants to be a hook.
8. Context: sharing without passing props everywhere
Some things are needed everywhere, like showing a "Photo downloaded" notification. Passing a showToast prop through ten layers of components is painful. Context makes a value available to everything below a provider:
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type = "success") => {
const id = nextId.current++;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => dismiss(id), type === "error" ? 5000 : 3500);
}, [dismiss]);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div aria-live="polite">{/* the toasts */}</div>
</ToastContext.Provider>
);
}It's mounted once in the root layout, and any component can call useToast().showToast("Saved"). We wrote it ourselves instead of adding a library: it's 100 lines, it matches the brand, and it adds nothing to the download size. The aria-live region means screen readers announce each message.
Context isn't a replacement for all props. Every component reading a context re-renders when its value changes, so keep it for things that are truly global and change rarely: the signed-in user, the theme, notifications.
9. Immutable updates
React decides whether to re-render by checking if the state value is a new object. Changing an array in place (list.push(x)) keeps the same object, so React may never notice. Always create a new one.
Here's how the shared gallery adds the next page of photos:
setGallery((prev) => {
if (!prev) return prev;
const current = prev.items;
const seen = new Set(current.map((item) => item.id));
const merged = [...current, ...page.items.filter((item) => !seen.has(item.id))];
return { ...prev, items: merged, itemsTotal: page.total };
});Two techniques at once:
- The updater function (
prev => ...) receives the latest state, even if several updates are queued. Usinggalleryfrom the surrounding scope could use a stale copy. - Spreading (
[...current, ...],{ ...prev, ... }) creates new objects and leaves the old ones untouched. TheSetdrops any photo already shown, so a double fetch can never create duplicate tiles.
10. useMemo and useCallback
useMemo remembers a calculated value until its inputs change. useCallback does the same for a function.
const pageItems = useMemo(
() => media.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE),
[media, safePage],
);When should you use them? Not everywhere. They have their own cost, and most calculations are cheap. Use them when:
- the calculation is genuinely expensive (filtering thousands of items), or
- the value is passed to a child or an effect that compares it between renders. Our
loadMorefunction is wrapped inuseCallbackbecause an effect depends on it; a new function on every render would make that effect run on every render.
11. Server Components and Client Components
In Next.js's App Router, components run on the server by default. They can read files and databases directly, and their code never ships to the browser. Only components marked "use client" run in the browser, and only they can use state, effects or event handlers.
This blog is a good example. The page you're reading is a Server Component:
export default function EngineeringPostPage({ params }) {
const source = readPostSource(params.slug); // reads a .md file on the server
const html = renderMarkdown(source);
return <article dangerouslySetInnerHTML={{ __html: html }} />;
}The Markdown file, the renderer and the file-reading code never reach your browser; you receive finished HTML. The customer dashboard is the opposite: it's full of state, polling and clicks, so it's a Client Component.
The practical rule: start on the server, and add "use client" only to the parts that need interaction. Our scroll animation, Reveal, is a small client component wrapped around server-rendered content. The content stays server-rendered; only the animation code ships.
12. Code splitting: don't make everyone download everything
Our video player uses hls.js, a library for adaptive video streaming. Most pages have no video, so we load it only when a player actually starts:
useEffect(() => {
let cancelled = false;
(async () => {
const { default: Hls } = await import("hls.js"); // downloaded on demand
if (cancelled) return;
const hls = new Hls({ capLevelToPlayerSize: true });
hls.loadSource(streamUrl);
hls.attachMedia(videoRef.current!);
})();
return () => { cancelled = true; };
}, [streamUrl]);import() as a function tells the bundler to put hls.js in a separate file. The cancelled flag handles a race: if the customer closes the player before the download finishes, we don't attach a player to a video element that no longer exists.
The same idea applies to our 3D game: three.js is large, and only people who open the game should pay for it.
13. Accessibility and motion are part of the component
A component isn't finished when it looks right. A few habits from our code:
- The current page button has
aria-current="page", so screen readers announce which page you're on. - Toasts live in an
aria-liveregion so they're read out. - Every animation, from the mascot to the calendar, is turned off when the operating system's "reduce motion" setting is on (
prefers-reduced-motion). Some people get motion sickness from animation. - Buttons are real
<button>elements, so they work with a keyboard for free.
Where to go next
You now have the whole toolkit: components, props, state, effects with cleanup, refs, custom hooks, context, immutable updates, memoisation, Server Components and code splitting. Part 3 puts most of them to work on a real problem: a gallery that crashed phones, and what it took to fix it.
The best way to learn is to build something you'd use yourself. Take one idea from this post, like useOnScreen, and use it in your own project this week.