
ReactJS
Advanced callback patterns enable complex component interactions without prop drilling. Proper memoization, event delegation, and callback composition create performant, maintainable component systems.
Callback functions must maintain stable identity across renders to prevent unnecessary re-renders in memoized child components. `useCallback` solves this while dependency management ensures correctness.
Callback identity matters when children use memos. If a parent creates a new callback function every render, memoized children re-render even if logic hasn't changed. `useCallback` creates a stable identity and only updates when dependencies change.
import { memo, useCallback, useState } from "react";
// Parent component with callback
function ListContainer() {
const [items, setItems] = useState<string[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
// Without useCallback: new function every render, causes child re-renders
const handleItemClick = (id: string) => {
setSelectedId(id);
};
// With useCallback: stable identity, child only re-renders if dependencies change
const memoizedHandleClick = useCallback(
(id: string) => {
setSelectedId(id);
},
[] // No dependencies - callback never changes
);
return (
<div>
<ItemList
items={items}
selectedId={selectedId}
onItemClick={memoizedHandleClick}
/>
</div>
);
}
interface ItemListProps {
items: string[];
selectedId: string | null;
onItemClick: (id: string) => void;
}
// Memoized child - only re-renders if props change
const ItemList = memo<ItemListProps>(
({ items, selectedId, onItemClick }) => {
console.log("ItemList render");
return (
<div>
{items.map((item) => (
<Item
key={item}
id={item}
isSelected={selectedId === item}
onClick={onItemClick}
/>
))}
</div>
);
},
(prev, next) => {
// Custom comparison: true = skip re-render
return (
prev.items === next.items &&
prev.selectedId === next.selectedId &&
prev.onItemClick === next.onItemClick // Stable identity matters!
);
}
);
interface ItemProps {
id: string;
isSelected: boolean;
onClick: (id: string) => void;
}
const Item = memo<ItemProps>(({ id, isSelected, onClick }) => (
<div
onClick={() => onClick(id)}
style={{
padding: "8px",
backgroundColor: isSelected ? "#e0e7ff" : "white",
}}
>
{id}
</div>
));
// Dependency tracking: callback updates when dependencies change
function FilteredListContainer() {
const [items, setItems] = useState<{ id: string; category: string }[]>([]);
const [filter, setFilter] = useState("");
// Callback dependency on filter ensures it updates
const handleFilter = useCallback(
(category: string) => {
setFilter(category);
// filter is in dependency array, so this updates when filter changes
},
[filter] // Callback recreated when filter changes
);
return (
<FilteredList items={items} onFilter={handleFilter} />
);
}<details>
<summary>📚 More Examples</summary>
// Example 2: Complex callback with multiple dependencies
interface DataFetcherProps {
userId: string;
filters: Record<string, any>;
onDataLoad: (data: any) => void;
onError: (error: Error) => void;
}
function DataFetcher({
userId,
filters,
onDataLoad,
onError,
}: DataFetcherProps) {
const [isLoading, setIsLoading] = useState(false);
// Callback with multiple dependencies
const handleFetch = useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch(
`/api/users/${userId}/data?${new URLSearchParams(filters)}`
);
const data = await response.json();
onDataLoad(data);
} catch (error) {
onError(error as Error);
} finally {
setIsLoading(false);
}
}, [userId, filters, onDataLoad, onError]); // All deps matter!
useEffect(() => {
handleFetch();
}, [handleFetch]);
return <div>{isLoading && "Loading..."}</div>;
}
// Memoize parent callbacks to prevent child re-fetches
function UserDataContainer({ userId }: { userId: string }) {
const [data, setData] = useState(null);
const [error, setError] = useState<Error | null>(null);
const memoizedSetData = useCallback(setData, []);
const memoizedSetError = useCallback(setError, []);
return (
<DataFetcher
userId={userId}
filters={{}}
onDataLoad={memoizedSetData}
onError={memoizedSetError}
/>
);
}</details>
Event delegation reduces prop drilling and improves performance by handling events at a higher level in the component tree.
Event delegation works by attaching listeners to parent elements and determining target from event object. This reduces the number of event listeners and enables dynamic child handling without re-renders.
import { SyntheticEvent } from "react";
// Traditional: prop drilling callbacks to each item
function TraditionalList({
items,
}: {
items: Array<{ id: string; name: string }>;
}) {
const handleItemClick = (id: string) => {
console.log("Clicked item:", id);
};
const handleItemDelete = (id: string) => {
console.log("Deleting item:", id);
};
return (
<div>
{items.map((item) => (
<ListItem
key={item.id}
item={item}
onClick={() => handleItemClick(item.id)}
onDelete={() => handleItemDelete(item.id)}
/>
))}
</div>
);
}
// Delegated: single handler at parent level
function DelegatedList({
items,
}: {
items: Array<{ id: string; name: string }>;
}) {
const handleListClick = (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const action = target.dataset.action;
const itemId = target.dataset.itemId;
if (!action || !itemId) return;
switch (action) {
case "click":
console.log("Clicked item:", itemId);
break;
case "delete":
console.log("Deleting item:", itemId);
break;
}
};
return (
<div onClick={handleListClick}>
{items.map((item) => (
<div key={item.id} data-item-id={item.id}>
<span>{item.name}</span>
<button data-action="delete" data-item-id={item.id}>
Delete
</button>
</div>
))}
</div>
);
}
// Callback composition: combining callbacks
function handleError(error: Error) {
console.error(error);
}
function handleSuccess(data: any) {
console.log("Success:", data);
}
// Compose handlers
const handleComplete = (error: Error | null, data: any) => {
if (error) {
handleError(error);
} else {
handleSuccess(data);
}
};
// Create higher-order callback composers
function composeCallbacks<Args extends any[]>(
...callbacks: Array<(...args: Args) => void>
) {
return (...args: Args) => {
callbacks.forEach((cb) => cb(...args));
};
}
const handleMultipleActions = composeCallbacks(
(data: any) => console.log("Log:", data),
(data: any) => updateUI(data),
(data: any) => sendAnalytics(data)
);<details>
<summary>📚 More Examples</summary>
// Example 2: Callback adapter pattern for API compatibility
interface LegacyCallbackProps {
onSuccess: (data: any) => void;
onError: (error: string) => void;
}
interface ModernCallbackProps {
onComplete: (result: { status: "success" | "error"; data?: any; error?: Error }) => void;
}
// Adapter: convert modern callback to legacy
function adaptCallback(
onComplete: ModernCallbackProps["onComplete"]
): LegacyCallbackProps {
return {
onSuccess: (data) =>
onComplete({ status: "success", data }),
onError: (error) =>
onComplete({ status: "error", error: new Error(error) }),
};
}
// Use legacy component with modern callback
function ModernComponent() {
const handleComplete = (result: any) => {
if (result.status === "success") {
console.log("Got data:", result.data);
} else {
console.error("Error:", result.error);
}
};
const legacyProps = adaptCallback(handleComplete);
return <LegacyComponent {...legacyProps} />;
}</details>
TypeScript enables type-safe callbacks that catch errors at compile time. Generics allow flexible callback signatures while maintaining safety.
// Type-safe callback definition
type ChangeHandler<T> = (value: T) => void;
type SubmitHandler<T> = (data: T) => Promise<void>;
type ErrorHandler = (error: Error) => void;
interface FormFieldProps<T> {
value: T;
onChange: ChangeHandler<T>;
onError?: ErrorHandler;
}
function FormField<T>({
value,
onChange,
onError,
}: FormFieldProps<T>) {
return (
<input
value={String(value)}
onChange={(e) => {
try {
onChange(e.target.value as T);
} catch (error) {
onError?.(error as Error);
}
}}
/>
);
}
// Generic form handler
interface FormState {
email: string;
password: string;
age: number;
}
function LoginForm() {
const [form, setForm] = useState<FormState>({
email: "",
password: "",
age: 0,
});
const handleFieldChange = useCallback<ChangeHandler<any>>(
(value) => {
// Determine which field changed based on value type
if (typeof value === "string") {
setForm((f) => ({ ...f, email: value }));
}
},
[]
);
const handleSubmit = useCallback<SubmitHandler<FormState>>(
async (data) => {
await api.login(data);
},
[]
);
return (
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit(form);
}}
>
<FormField value={form.email} onChange={handleFieldChange} />
</form>
);
}
// Callback reducer pattern: type-safe state updates through callbacks
type Action<T> = { type: "set"; payload: T } | { type: "reset" } | { type: "undo" };
interface CallbackReducerProps<T> {
initialValue: T;
onAction: (action: Action<T>) => void;
}
function useCallbackReducer<T>(initialValue: T) {
const [value, setValue] = useState(initialValue);
const [history, setHistory] = useState<T[]>([initialValue]);
const dispatch = useCallback((action: Action<T>) => {
switch (action.type) {
case "set":
setValue(action.payload);
setHistory((h) => [...h, action.payload]);
break;
case "reset":
setValue(initialValue);
setHistory([initialValue]);
break;
case "undo":
if (history.length > 1) {
const newHistory = history.slice(0, -1);
setValue(newHistory[newHistory.length - 1]);
setHistory(newHistory);
}
break;
}
}, [initialValue, history]);
return { value, dispatch };
}<details>
<summary>📚 More Examples</summary>
// Example 2: Async callback with proper typing
type AsyncCallback<T, R = void> = (data: T) => Promise<R>;
interface AsyncButtonProps<T> {
onClick: AsyncCallback<T>;
data: T;
isLoading?: boolean;
disabled?: boolean;
}
function AsyncButton<T>({
onClick,
data,
isLoading = false,
disabled = false,
}: AsyncButtonProps<T>) {
const [loading, setLoading] = useState(false);
const handleClick = useCallback(async () => {
setLoading(true);
try {
await onClick(data);
} finally {
setLoading(false);
}
}, [onClick, data]);
return (
<button onClick={handleClick} disabled={disabled || loading || isLoading}>
{loading ? "Loading..." : "Submit"}
</button>
);
}</details>
// Throttle callback to limit execution frequency
function useThrottledCallback<Args extends any[]>(
callback: (...args: Args) => void,
delay: number
) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
return useCallback(
(...args: Args) => {
if (timeoutRef.current) return;
callback(...args);
timeoutRef.current = setTimeout(() => {
timeoutRef.current = null;
}, delay);
},
[callback, delay]
);
}
// Debounce callback to delay execution
function useDebouncedCallback<Args extends any[]>(
callback: (...args: Args) => void,
delay: number
) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
return useCallback(
(...args: Args) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => callback(...args), delay);
},
[callback, delay]
);
}Ready to practice? Challenges | Next: Enterprise Component Design
Resources
Ojasa Mirai
Master AI-powered development skills through structured learning, real projects, and verified credentials. Whether you're upskilling your team or launching your career, we deliver the skills companies actually need.
Learn Deep • Build Real • Verify Skills • Launch Forward