
Every React developer learns useEffect early on. It’s the escape hatch for syncing with the outside world — fetching data, subscribing to events, manipulating the DOM. But as your React applications grow in complexity, you start to notice that useEffect is doing too much work.
At Brainstorm Force, building complex dashboard UIs with real-time data, multi-step forms, and deeply nested state, we found that over-relying on useEffect leads to subtle bugs, performance issues, and components that are nearly impossible to test.
This is a deep dive into the advanced React patterns that helped us build more maintainable and predictable UIs.
The classic anti-pattern is using useEffect to derive state:
// ❌ Anti-pattern: Using useEffect for derived state
function OrderSummary({ items, coupon }) {
const [total, setTotal] = useState(0);
const [discount, setDiscount] = useState(0);
const [finalTotal, setFinalTotal] = useState(0);
useEffect(() => {
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
setTotal(subtotal);
}, [items]);
useEffect(() => {
if (coupon?.type === 'percent') {
setDiscount(total * coupon.value / 100);
} else {
setDiscount(coupon?.value ?? 0);
}
}, [total, coupon]);
useEffect(() => {
setFinalTotal(total - discount);
}, [total, discount]);
// This has race conditions, stale closure bugs, and unnecessary re-renders
}
The correct approach is to derive state during render, not in effects:
// ✅ Derive state during render
function OrderSummary({ items, coupon }) {
const total = useMemo(
() => items.reduce((sum, item) => sum + item.price * item.qty, 0),
[items]
);
const discount = useMemo(() => {
if (!coupon) return 0;
return coupon.type === 'percent' ? total * coupon.value / 100 : coupon.value;
}, [total, coupon]);
const finalTotal = total - discount;
// Zero effects, zero bugs, renders are pure functions of props/state
}
When component state has multiple sub-values that change together with complex transition rules, useReducer is far superior to multiple useState calls. It makes state transitions explicit and testable:
type FormState =
| { status: 'idle' }
| { status: 'validating' }
| { status: 'submitting'; data: FormData }
| { status: 'success'; submittedAt: Date }
| { status: 'error'; message: string; retries: number };
type FormAction =
| { type: 'START_VALIDATE' }
| { type: 'SUBMIT'; data: FormData }
| { type: 'SUCCESS' }
| { type: 'ERROR'; message: string }
| { type: 'RETRY' };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'START_VALIDATE':
if (state.status !== 'idle' && state.status !== 'error') return state;
return { status: 'validating' };
case 'SUBMIT':
if (state.status !== 'validating') return state;
return { status: 'submitting', data: action.data };
case 'SUCCESS':
if (state.status !== 'submitting') return state;
return { status: 'success', submittedAt: new Date() };
case 'ERROR':
if (state.status !== 'submitting') return state;
return {
status: 'error',
message: action.message,
retries: state.status === 'error' ? state.retries + 1 : 0,
};
case 'RETRY':
if (state.status !== 'error') return state;
return { status: 'idle' };
default:
return state;
}
}
function ContactForm() {
const [state, dispatch] = useReducer(formReducer, { status: 'idle' });
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
dispatch({ type: 'START_VALIDATE' });
const formData = new FormData(e.currentTarget);
// Validate...
dispatch({ type: 'SUBMIT', data: formData });
try {
await submitContact(formData);
dispatch({ type: 'SUCCESS' });
} catch (err) {
dispatch({ type: 'ERROR', message: err.message });
}
}
return (
<form onSubmit={handleSubmit}>
{state.status === 'error' && (
<ErrorAlert message={state.message} onRetry={() => dispatch({ type: 'RETRY' })} />
)}
{state.status === 'success' && <SuccessBanner submittedAt={state.submittedAt} />}
<button disabled={state.status === 'submitting'}>
{state.status === 'submitting' ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
The reducer is a pure function — easily unit tested without rendering anything:
test('ERROR action increments retries on second failure', () => {
const state1 = formReducer({ status: 'submitting', data: fd }, { type: 'ERROR', message: 'Network error' });
expect(state1).toEqual({ status: 'error', message: 'Network error', retries: 0 });
const state2 = formReducer({ status: 'submitting', data: fd }, { type: 'ERROR', message: 'Timeout' });
expect(state2.retries).toBe(0); // retries are per-submission
});
Complex components often mix UI rendering with business logic. Extract logic into custom hooks to achieve separation of concerns:
// ❌ Logic mixed into component — hard to test, hard to reuse
function CourseEnrollmentButton({ courseId }: { courseId: number }) {
const [enrolled, setEnrolled] = useState(false);
const [loading, setLoading] = useState(true);
const [enrolling, setEnrolling] = useState(false);
const { user } = useAuth();
useEffect(() => {
if (!user) return;
checkEnrollmentStatus(courseId, user.id).then(status => {
setEnrolled(status.enrolled);
setLoading(false);
});
}, [courseId, user]);
async function handleEnroll() {
setEnrolling(true);
try {
await enrollInCourse(courseId);
setEnrolled(true);
} finally {
setEnrolling(false);
}
}
// ... render
}
// ✅ Extract into a custom hook
function useCourseEnrollment(courseId: number) {
const { user } = useAuth();
const queryClient = useQueryClient();
const { data: enrolled, isLoading } = useQuery({
queryKey: ['enrollment', courseId, user?.id],
queryFn: () => checkEnrollmentStatus(courseId, user!.id),
enabled: !!user,
select: (data) => data.enrolled,
});
const { mutate: enroll, isPending: isEnrolling } = useMutation({
mutationFn: () => enrollInCourse(courseId),
onSuccess: () => {
queryClient.setQueryData(['enrollment', courseId, user?.id], { enrolled: true });
},
});
return { enrolled, isLoading, isEnrolling, enroll };
}
// Component is now purely presentational
function CourseEnrollmentButton({ courseId }: { courseId: number }) {
const { enrolled, isLoading, isEnrolling, enroll } = useCourseEnrollment(courseId);
if (isLoading) return <Skeleton className="h-10 w-32" />;
if (enrolled) return <EnrolledBadge />;
return (
<Button onClick={() => enroll()} disabled={isEnrolling}>
{isEnrolling ? 'Enrolling...' : 'Enroll Now'}
</Button>
);
}
Compound components use React Context to share state between a parent and its children, creating flexible, composable component APIs:
// Tabs compound component
interface TabsContextValue {
activeTab: string;
setActiveTab: (id: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabs() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tabs.* must be used inside <Tabs>');
return ctx;
}
function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext>
);
}
function TabList({ children }: { children: ReactNode }) {
return <div role="tablist" className="flex gap-2 border-b">{children}</div>;
}
function Tab({ id, children }: { id: string; children: ReactNode }) {
const { activeTab, setActiveTab } = useTabs();
return (
<button
role="tab"
aria-selected={activeTab === id}
onClick={() => setActiveTab(id)}
className={cn('tab', activeTab === id && 'tab-active')}
>
{children}
</button>
);
}
function TabPanel({ id, children }: { id: string; children: ReactNode }) {
const { activeTab } = useTabs();
if (activeTab !== id) return null;
return <div role="tabpanel">{children}</div>;
}
// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Usage — clean, readable, no prop drilling
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab id="overview">Overview</Tabs.Tab>
<Tabs.Tab id="curriculum">Curriculum</Tabs.Tab>
<Tabs.Tab id="reviews">Reviews</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="overview"><CourseOverview /></Tabs.Panel>
<Tabs.Panel id="curriculum"><CourseCurriculum /></Tabs.Panel>
<Tabs.Panel id="reviews"><CourseReviews /></Tabs.Panel>
</Tabs>
When you want to share complex behavior but give consumers full control over rendering, use the render props pattern (or headless component pattern):
// Headless combobox — handles keyboard navigation, ARIA, filtering
// Consumer controls all rendering
function Combobox<T>({
items,
itemToString,
onSelect,
render,
}: {
items: T[];
itemToString: (item: T) => string;
onSelect: (item: T) => void;
render: (props: ComboboxRenderProps<T>) => ReactNode;
}) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [cursor, setCursor] = useState(0);
const filtered = useMemo(
() => items.filter(item =>
itemToString(item).toLowerCase().includes(query.toLowerCase())
),
[items, query, itemToString]
);
function handleKeyDown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowDown': setCursor(c => Math.min(c + 1, filtered.length - 1)); break;
case 'ArrowUp': setCursor(c => Math.max(c - 1, 0)); break;
case 'Enter': onSelect(filtered[cursor]); setOpen(false); break;
case 'Escape': setOpen(false); break;
}
}
return render({
query, setQuery, open, setOpen,
filtered, cursor, handleKeyDown,
getItemProps: (item: T, index: number) => ({
role: 'option',
'aria-selected': index === cursor,
onClick: () => { onSelect(item); setOpen(false); },
}),
});
}
// Consumer can style it however they want
<Combobox
items={users}
itemToString={u => u.name}
onSelect={setSelectedUser}
render={({ query, setQuery, open, filtered, getItemProps }) => (
<div className="relative">
<input
value={query}
onChange={e => setQuery(e.target.value)}
className="input"
placeholder="Search users..."
/>
{open && (
<ul className="absolute top-full w-full bg-white shadow-lg rounded-xl z-10">
{filtered.map((user, i) => (
<li key={user.id} {...getItemProps(user, i)} className="px-4 py-2 hover:bg-blue-50 cursor-pointer">
{user.name}
</li>
))}
</ul>
)}
</div>
)}
/>
React’s concurrent features (Suspense + Error Boundaries + use()) transform async UI from imperative to declarative:
// Wrap async data in a Resource
function CourseListPage() {
return (
<ErrorBoundary fallback={<CourseListError />}>
<Suspense fallback={<CourseListSkeleton />}>
<CourseList />
</Suspense>
</ErrorBoundary>
);
}
// Inside — no loading state, no error state, just data
async function CourseList() {
const courses = await getCourses(); // React 19 async component
return (
<div className="grid grid-cols-3 gap-6">
{courses.map(course => <CourseCard key={course.id} course={course} />)}
</div>
);
}
The most common mistake with React performance is either memoizing everything (premature optimization) or nothing (performance regressions). The rule:
// When NOT to memoize
const double = useMemo(() => value * 2, [value]); // Simple math — don't bother
const handleClick = useCallback(() => setCount(c => c + 1), []); // No children, no effect dep
// When TO memoize
const sortedFilteredItems = useMemo(
() => items
.filter(item => item.status === filter)
.sort((a, b) => b.createdAt - a.createdAt),
[items, filter]
); // Potentially O(n log n) — worth memoizing
const contextValue = useMemo(
() => ({ user, logout, updateProfile }),
[user]
); // Prevents all context consumers from re-rendering on every parent render
The patterns in this article — reducers for state machines, custom hooks for logic extraction, compound components for API design, render props for headless behavior, and Suspense for async UI — aren’t academic concepts. They’re the day-to-day toolkit of teams building serious React applications.
The common thread: React components work best when they’re pure functions of state. The more you move side effects, derivations, and business logic out of the render phase and into the right abstractions, the more predictable, testable, and maintainable your UI becomes.