
Refactoring React: Extracting Logic into Custom Hooks
Component growing too large? Logic mixed with UI? Learn how to extract business logic into Custom Hooks for cleaner, testable code.

Component growing too large? Logic mixed with UI? Learn how to extract business logic into Custom Hooks for cleaner, testable code.
A deep dive into Robert C. Martin's Clean Architecture. Learn how to decouple your business logic from frameworks, databases, and UI using Entities, Use Cases, and the Dependency Rule. Includes Screaming Architecture and Testing strategies.

Why is the CPU fast but the computer slow? I explore the revolutionary idea of the 80-year-old Von Neumann architecture and the fatal bottleneck it left behind.

Connecting incompatible interfaces. How to make old code work with new code without rewriting.

ChatGPT answers questions. AI Agents plan, use tools, and complete tasks autonomously. Understanding this difference changes how you build with AI.

Opened SignupForm.tsx and scrolled forever.
10 useState, 3 useEffect, handlers everywhere.
The actual return <form>...</form> was buried at the bottom.
"Where is the email validation logic?"
I had to Ctrl+F to find it.
Need to change button color? Wade through a desert of logic to find the UI oasis.
I thought Custom Hooks were ONLY for Shared Logic (like useWindowSize).
Since Signup logic is used only in SignupForm, I didn't see the point of extracting it.
I missed the real value: Separation of Concerns. Even if used once, extracting makes code readable.
I viewed it as "Restaurant Roles."
The Manager doesn't need to know how to chop onions.
Just calling usePasta() is enough.
Identify related state and functions in SignupForm.
(Email, Password, Submit Handler, Validation).
useSignupLogic.tsCut everything except JSX/UI and paste it here.
// useSignupLogic.ts
import { useState } from 'react';
export function useSignupLogic() {
const [email, setEmail] = useState('');
const handleSubmit = async () => {
// ... complex API logic ...
};
// Return only what the UI needs
return {
form: { email },
actions: { setEmail, handleSubmit },
isValid: email.includes('@')
};
}
Logic is delegated. UI becomes clean.
// SignupForm.tsx
import { useSignupLogic } from './useSignupLogic';
export function SignupForm() {
const { form, actions, isValid } = useSignupLogic();
return (
<form onSubmit={actions.handleSubmit}>
<input
value={form.email}
onChange={e => actions.setEmail(e.target.value)}
/>
<button disabled={!isValid}>Sign Up</button>
</form>
);
}
500 lines -> 50 lines. Designer wants changes? Edit JSX safely. Backend logic changes? Open the Hook.
Pushing this logic leads to Headless UI.
Libraries like Radix UI or TanStack Table.
They provide Functionality (Hooks) but leave Styling (UI) to you.
// TanStack Table Example
const { getRowModel } = useReactTable({...});
return (
<table>
{/* Render however I want */}
{getRowModel().rows.map(...)}
</table>
)
Maximum flexibility with zero design constraints.
Testing logic inside UI is hard.
With Hooks, use renderHook to test logic in isolation.
// useSignupLogic.test.ts
test('isValid is false for short password', () => {
const { result } = renderHook(() => useSignupLogic());
act(() => {
result.current.actions.setPassword('123');
});
expect(result.current.isValid).toBe(false);
});
Verify business logic without rendering a single DOM element.
The true power of Custom Hooks comes when you Assemble them like LEGO blocks.
If you have useUser() and usePermissions(), combine them to make useAdminAction().
function useAdminAction() {
const user = useUser();
const { canDelete } = usePermissions();
const deleteUser = async (targetId: string) => {
if (!user.isAdmin || !canDelete) {
throw new Error("Access Denied");
}
await api.deleteUser(targetId);
};
return { deleteUser };
}
The UI component doesn't need to know about isAdmin or canDelete.
It just calls useAdminAction().deleteUser().
You created an Abstraction Layer.
Sometimes you need strict Type Safety with maximum reuse. Let's make a generic selector.
function useSelection<T>(initialItems: T[]) {
const [selected, setSelected] = useState<T[]>([]);
const toggle = (item: T) => {
setSelected(prev =>
prev.includes(item)
? prev.filter(i => i !== item)
: [...prev, item]
);
};
return { selected, toggle };
}
// Usage
const { selected: selectedUsers, toggle } = useSelection<User>(users);
const { selected: selectedTags, toggle } = useSelection<string>(tags);
Whether User object or String, we reuse 100% of the logic.
TypeScript Generics + Hooks = Productivity Beast.
A real story from production.
An E-commerce Product List Page (ProductList.tsx) was exploding.
Over 20 filter conditions.
(Category, Price Range, Brand, Color, Size, Rating, Same-day Delivery...)
Had to sync with URL Query Parameters (?category=top...).
useEffect spaghetti caused bugs where "Back Button" broke the filters.
useProductFilterExtracted this massive blob into useProductFilter.ts.
setFilter, resetFilter, applyFilter.// ProductList.tsx (Refactored)
function ProductList() {
// UI knows NOTHING about URL parsing!
const { filters, updateFilter } = useProductFilter();
const { data } = useProducts(filters);
return (
<Layout>
<Sidebar>
{/* Just pass the handler */}
<CategoryFilter value={filters.category} onChange={updateFilter} />
<PriceFilter value={filters.price} onChange={updateFilter} />
</Sidebar>
<Main>
{data.map(product => <ProductCard product={product} />)}
</Main>
</Layout>
);
}
ProductList.tsx now focuses ONLY on Layout.
Even if filter logic gets complex (e.g., "Reset Brand when Price changes"), the component remains untouched.
Custom Hooks make advanced patterns like Optimistic Updates reusable. Make the "Like" button turn red INSTANTLY, without waiting for the server.
function useOptimisticMutation<T>(
key: string,
mutationFn: (data: T) => Promise<void>
) {
const queryClient = useQueryClient();
return useMutation({
onMutate: async (newData) => {
// 1. Cancel outgoing fetches
await queryClient.cancelQueries([key]);
// 2. Snapshot previous value
const previousData = queryClient.getQueryData([key]);
// 3. Update UI instantly
queryClient.setQueryData([key], newData);
return { previousData };
},
onError: (err, newData, context) => {
// 4. Rollback on error
queryClient.setQueryData([key], context.previousData);
},
mutationFn,
});
}
Imagine writing this 15-line logic inside every component. Nightmare.
Encapsulate it. useOptimisticMutation('likes', likeApi). Done.
Don't split atoms. Too many small hooks create Hook Hell.
// ❌ Too much
const { data } = useData();
const { sorted } = useSort(data);
const { filtered } = useFilter(sorted);
const { formatted } = useFormat(filtered);
Tracing data flow here is a nightmare. You have to jump through 5 files to understand what's happening. Aim for Facade Pattern.
// ✅ Facade
const { viewData, controllers } = useProductPageView();
// Inside: handles sorting, filtering, and formatting internally.
Expose clean inputs and outputs. Hide the messy pipes inside ONE big hook, rather than chaining 10 small ones in the Component.
Q: Should I extract a single useEffect?
A: Not always. But if it's Business Logic (Data Fetching, Event Listener), yes. If it's UI Logic (Scroll, Focus), keep it in the component.
Q: Won't I have too many files? A: Yes. But "Many Small Files" are easier to manage than "One Giant Monolith." IDEs (Cmd+P) make navigation instant.
Q: I want to share STATE, not just logic. A: Custom Hooks reuse Logic, not State Values. Each call creates a fresh state. To share the value (Single Source of Truth), use Context API, Recoil, or Zustand.