These aren't just syntax notes — they're the lessons that stuck with me while building real apps.
Table of Contents
- Using ...props (Spread Operator)
- children Prop – Composition, Layouts & Render Props
- useRef() – DOM Access & Persistent Values
- useImperativeHandle() – Custom Ref API
- forwardRef() – Pass Refs Through
- createPortal() – Render Outside the Tree
- useState() – Local State & Functional Updates
- createContext + Provider + Consumer + useContext()
- Closing Thoughts
Using ...props (Spread Operator)
When I first started building reusable components, I quickly ran into a problem: prop explosion.
If you wrap an <input> or <button>, do you really want to explicitly pass every possible prop like onClick, onChange, disabled, type, etc.?
The answer is no — that's brittle and inflexible.
The ...props spread operator allows you to collect all remaining props and forward them. This makes wrappers more generic, reusable, and future‑proof.
But with great power comes caution: spreading props blindly can also leak unwanted props to the DOM, causing warnings or security issues (e.g., passing user data as attributes).
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary";
};
export function Button({ variant = "primary", ...props }: ButtonProps) {
return (
<button
{...props}
style={{
padding: "10px 16px",
borderRadius: 8,
border: "none",
color: "white",
background: variant === "primary" ? "#2563eb" : "#6b7280",
cursor: "pointer",
...(props.style || {}),
}}
>
{props.children}
</button>
);
}
+ Use when
- You want wrapper components to accept all native HTML props
- You're building a design system and don't want to reinvent every attribute
- You want consumers to override defaults with inline
styleor extra props
- Avoid when
- You need a strict, explicit API
- You might pass unsafe or irrelevant props to DOM elements
- You care about type safety and want to avoid "prop soup"
children Prop – Composition, Layouts & Render Props
The children prop is React's secret weapon. Instead of rigid inheritance, React embraced composition — letting you pass UI blocks into other components.
It's what makes layouts, cards, and modals feel natural in React. The trade‑off? Overusing children can make components harder to reason about if you don't know what will be injected inside. Explicit props can sometimes be clearer.
export function Card({ children }: { children: React.ReactNode }) {
return <div style={{ padding: 16, border: "1px solid #e5e7eb" }}>{children}</div>;
}
type ListProps<T> = {
items: T[];
children: (item: T, index: number) => React.ReactNode;
};
export function List<T>({ items, children }: ListProps<T>) {
return <ul>{items.map((it, i) => <li key={i}>{children(it, i)}</li>)}</ul>;
}
+ Use when
- You want flexible containers (Card, Panel, Modal, Layout)
- You need composability (children decide structure, not parent)
- You want advanced render patterns (render props, slots, compound components)
- Avoid when
- The content is always fixed
- Type checking matters more than flexibility
- You want predictable rendering
useRef() – DOM Access & Persistent Values
React's declarative model often means you don't touch the DOM directly. But sometimes, you need to: focusing an input, scrolling, controlling a video.
That's where useRef shines.
What surprised me most was that useRef isn't just for DOM nodes. It also acts like a box to persist mutable values across renders without triggering re‑renders.
This makes it ideal for caching, storing timers, or remembering "previous" values.
export function FocusDemo() {
const ref = React.useRef<HTMLInputElement>(null);
return (
<>
<input ref={ref} placeholder="Focus me" />
<button onClick={() => ref.current?.focus()}>Focus</button>
</>
);
}
export function Ticker() {
const [t, setT] = React.useState(0);
const intervalRef = React.useRef<number | null>(null);
const start = () => {
if (intervalRef.current) return;
intervalRef.current = window.setInterval(() => setT((p) => p + 1), 1000);
};
const stop = () => {
if (!intervalRef.current) return;
clearInterval(intervalRef.current);
intervalRef.current = null;
};
return (
<div>
<div>Ticks: {t}</div>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
+ Use when
- You need direct DOM access
- You want to store values that don't affect rendering
- You need a mutable container to avoid re‑render churn
- Avoid when
- The value should trigger UI updates
- You risk hiding important state changes
- You're trying to sidestep React's state model
useImperativeHandle() – Custom Ref API
Normally, refs just expose the raw DOM node. But sometimes, that's too much power.
I wanted parents to call methods on a child (e.g., .focus(), .clear()), but not mess with the DOM directly.
That's when I discovered useImperativeHandle: it lets you define exactly what the ref exposes.
This gives your components a clean, safe API surface.
type InputHandle = { focus: () => void; clear: () => void };
export const ImperativeInput = React.forwardRef<InputHandle, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => {
const inputRef = React.useRef<HTMLInputElement>(null);
React.useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
clear: () => {
if (inputRef.current) inputRef.current.value = "";
},
}));
return <input ref={inputRef} {...props} />;
}
);
+ Use when
- You need to expose a limited set of methods
- You're building reusable libraries/components
- You want to encapsulate complex internals but still allow limited parent control
- Avoid when
- Props/state can achieve the same effect declaratively
- You'd end up exposing the whole DOM node anyway
- You find yourself mimicking imperative patterns
forwardRef() – Pass Refs Through
By default, refs don't "pass through" your custom components. This frustrated me the first time I wrapped an <input> and couldn't call .focus() from the parent.
forwardRef solves this. It allows your wrapper to accept a ref and forward it to the underlying DOM node.
export const FancyButton = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ children, ...props }, ref) => (
<button
ref={ref}
{...props}
style={{ padding: "10px 16px", borderRadius: 9999, background: "#111827", color: "white" }}
>
{children}
</button>
)
);
+ Use when
- Wrapping DOM elements and still want parent access
- Integrating with form libraries or animation libs
- Building reusable UI kits where external control is expected
- Avoid when
- No parent ever needs access
- You can solve the problem declaratively
createPortal() – Render Outside the Tree
One of the first painful CSS problems I hit: modals inside containers with overflow: hidden.
The modal got clipped. Same for dropdowns and tooltips.
That's when I found createPortal. It lets you render content outside the DOM hierarchy, while keeping it in the React tree.
<body>
<div id="root"></div>
<div id="modal-root"></div>
</body>
import ReactDOM from "react-dom";
export function SafeModal({ children }: { children: React.ReactNode }) {
const el = document.createElement("div");
React.useEffect(() => {
let modalRoot = document.getElementById("modal-root");
if (!modalRoot) {
modalRoot = document.createElement("div");
modalRoot.setAttribute("id", "modal-root");
document.body.appendChild(modalRoot);
}
modalRoot.appendChild(el);
return () => {
modalRoot?.removeChild(el);
};
}, [el]);
return ReactDOM.createPortal(children, el);
}
+ Use when
- Building modals, tooltips, dropdowns
- Accessibility requires content to be top‑level
- You want to isolate z‑index and overflow issues
- Avoid when
- Standard DOM hierarchy is fine
- Overuse can make DOM harder to debug
useState() – Local State & Functional Updates
The very first hook I learned. It felt simple, but I hit pitfalls quickly.
The biggest: state updates are async and batched. Writing setCount(count + 1) three times in a row doesn't increment by three.
The fix: functional updates (setCount(prev => prev + 1)). That was my first big "aha" moment.
export function Counter() {
const [count, setCount] = React.useState(0);
const add3 = () => {
setCount((p) => p + 1);
setCount((p) => p + 1);
setCount((p) => p + 1);
};
return (
<div>
<div>Count: {count}</div>
<button onClick={() => setCount((p) => p + 1)}>+1</button>
<button onClick={add3}>+3</button>
</div>
);
}
+ Use when
- State is simple and local
- You need functional updates
- You want quick prototypes or simple form controls
- Avoid when
- State transitions are complex (use
useReducer) - You're tempted to nest deeply
- You need global/shared state
createContext + Provider + Consumer + useContext()
Prop drilling (passing the same prop down 4 levels) is painful. Context solves this.
It lets you define global values (theme, auth, user, config) and read them anywhere in the component tree.
type User = { name: string } | null;
type AuthApi = { user: User; login: (name: string) => void; logout: () => void };
const AuthContext = React.createContext<AuthApi | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = React.useState<User>(null);
const login = (name: string) => setUser({ name });
const logout = () => setUser(null);
return <AuthContext.Provider value={{ user, login, logout }}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = React.useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+ Use when
- You want to avoid prop drilling
- Multiple components need the same shared state
- You want a lightweight alternative to Redux
- Avoid when
- The value updates very frequently
- You start dumping all state into one context
- You need fine‑grained performance tuning
Closing Thoughts
This was my deep dive into React's first set of hooks and patterns.
Each of these solved real problems I hit when moving from toy apps to production apps.
...props→ saved me from brittle prop forwardingchildren→ unlocked composition over inheritanceuseRef→ taught me the difference between reactive vs persistent stateuseImperativeHandle+forwardRef→ gave me safe imperative control when neededcreatePortal→ fixed my first modal disasteruseState→ introduced reactivity and immutabilitycreateContext→ finally freed me from prop drilling hell
This concludes Part 1 of my React learning series.
Next up in Part 2: lifecycle hooks (useEffect), performance patterns (useMemo, useCallback), and custom hooks.