Refactoring React: Extracting Logic into Custom Hooks
"Is This UI Code or Data Logic?"
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.
What Confused Me Initially? (Hooks = Reusable Only?)
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.
The 'Aha!' Moment (Chef vs. Manager)
I viewed it as "Restaurant Roles."
- Before (Fat Component): Manager cooks, serves, and calculates bills. Chaos.
- After (Custom Hook):
- Custom Hook (Chef): Handles cooking (Logic, Data). "Pasta ready!" Returns the dish (Data).
- Component (Manager): Handles serving (UI Rendering). Just places the pasta on the table (Screen).
The Manager doesn't need to know how to chop onions.
Just calling usePasta() is enough.
The Fix: Extract Logic
Step 1: Find Chunks
Identify related state and functions in SignupForm.
(Email, Password, Submit Handler, Validation).
Step 2: Create useSignupLogic.ts
Cut 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('@')
};
}
Step 3: Diet Component
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.
Deep Dive: Headless UI Pattern
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.
Application: Easy Testing
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.
7. Deep Dive: Hook Composition (Hooks using Hooks)
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.
8. Deep Dive: The Ultimate Generic Hook
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.
9. Case Study: The "Mega-Filter" Logic Escape
A real story from production.
An E-commerce Product List Page (ProductList.tsx) was exploding.
The Situation
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.
The Solution: useProductFilter
Extracted this massive blob into useProductFilter.ts.
- State: Manages all filter values.
- Sync: Two-way binding between State and URL.
- Action:
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.
10. Deep Dive: Optimistic UI Hook
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.
11. Anti-Pattern: Hook Hell (Over-Abstraction)
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.
12. FAQ
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.
13. One-Line Summary
Custom Hooks aren't just for reuse. Use them to slim down Fat Components. Separating Logic (Chef) from UI (Manager) is the key to maintainable code.