React's Real Refactor Isn't Components—It's Pulling Logic Into Custom Hooks
You've Split the Components, but App.tsx Is Still 300 Lines? Put Business Logic into Custom Hooks
Opening: You split the components, then what?
Look at this React project's directory structure, it's very standard:
src/
├── types/todo.ts # Type definitions
├── hooks/useTodos.ts # Custom Hook
├── components/
│ ├── TodoInput.tsx # Input component
│ ├── TodoItem.tsx # Todo item
│ ├── TodoList.tsx # List component
│ └── TodoFilter.tsx # Filter component
├── App.tsx
└── main.tsx
Types, Hooks, components—three layers of separation—looks very clean.
But most React beginners' projects look like this:
src/
├── App.tsx ← 300 lines, state, CRUD, filtering all crammed in here
├── TodoInput.tsx
└── TodoItem.tsx
The components are split—but only the JSX is split. The real business logic (useState, CRUD operations, filtering) is still all piled into App.tsx. You think you're doing componentization, but you're just moving JSX around.
This article uses a Todo project to demonstrate: how to pull "logic" out of components and put it into custom Hooks, so that components do only one thing—render UI.
Core Concept: Custom Hook = React Project's "Logic Layer"
A custom Hook is essentially a "pure logic function equipped with React superpowers"—it can use all Hooks like useState, useEffect, useContext, but produces no JSX. It is a layer of isolation between components and state.
Use an analogy:
Chef (Component) → Responsible for plating and serving (rendering UI)
Prep Cook (Hook) → Responsible for washing, cutting, preparing ingredients (preparing data + operation logic)
Fridge (state) → Stores ingredients (stores data)
The chef doesn't need to rummage through the fridge, wash vegetables, or cut meat himself. He just tells the prep cook "give me the ingredients for Kung Pao Chicken," and focuses on stir-frying and plating. A custom Hook is this prep cook—without it, the chef has to run back and forth between the stove and the fridge.
First, look at the architecture overview:
graph TD
A["App.tsx ─ Assembly Layer"] --> B["useTodos Hook"]
A --> C["TodoInput Component"]
A --> D["TodoList Component"]
A --> E["TodoFilter Component"]
B --> F["types/todo.ts"]
C -->|"onAdd"| B
D -->|"onToggle / onDelete"| B
E -->|"onFilterChange"| B
B -->|"todos, filter"| A
A -->|"props passed down"| C
A -->|"props passed down"| D
A -->|"props passed down"| E
The data flow is crystal clear: The Hook is the single "data source + operation center," App is only responsible for distribution, and components are only responsible for rendering.
Step 1: TypeScript Sets the "Rules" for Data First
Before writing any logic, define the data structure first. This is the first lesson of TypeScript + React—types first:
// types/todo.ts
// 🔑 interface defines the shape of an object—what each Todo looks like
export interface Todo {
id: string;
text: string;
completed: boolean;
}
// 🔑 type defines a union type—Filter can only be one of these three values
// ⚠️ Common mistake: using string instead of a union type. string compiles fine,
// but typing 'compleated' won't error, filtering won't work at runtime, and you'll debug for ages
export type Filter = 'all' | 'completed' | 'uncompleted';
These two lines of types are the "constitution" of the entire project. All subsequent code is written based on them, and TypeScript immediately errors when you deviate from the convention.
When to use interface vs type?
interface |
type |
|
|---|---|---|
| Suitable for | Describing object shapes | Union types, intersection types, primitive type aliases |
| In this project | Todo is an object → interface |
Filter is a choice of three → type |
| Interchangeable? | In most scenarios yes, but union types can only use type |
— |
Simple memory aid: Object shapes use
interface, string unions usetype.
Step 2: Put the Entire Set of Business Logic into One Hook
useTodos is the "brain" of this project. It owns all state and all operation methods. Components only need to get data and operation methods from it, and care about nothing else.
// hooks/useTodos.ts ── The project's "Logic Layer"
import { useState } from 'react';
import type { Todo, Filter } from '../types/todo';
export function useTodos() {
const [todos, setTodos] = useState<Todo[]>([]); // 🔑 Generic constraint: can only be a Todo array
const [filter, setFilter] = useState<Filter>('all'); // 🔑 Must conform to the Filter union type
// Add todo
const addTodo = (text: string) => {
if (!text.trim()) return; // ⚠️ Block empty strings or pure whitespace
const newTodo: Todo = {
id: Date.now().toString(), // 🔑 Timestamp as unique ID, simple version (production recommends nanoid)
text,
completed: false,
};
setTodos(prev => [...prev, newTodo]); // ✅ Functional update, avoids stale closures
};
// Toggle completion status
const toggleTodo = (id: string) => {
setTodos(prev =>
prev.map(item =>
item.id === id
? { ...item, completed: !item.completed } // 🔑 Immutable update: spread + overwrite
: item
)
);
};
// Delete todo
const deleteTodo = (id: string) => {
setTodos(prev => prev.filter(item => item.id !== id)); // filter returns a new array
};
// Clear completed
const clearCompleted = () => {
setTodos(prev => prev.filter(item => !item.completed));
};
return {
todos,
filter,
addTodo,
toggleTodo,
deleteTodo,
clearCompleted,
};
}
Every operation uses functional updates (prev => ...), not direct value passing. The reason is:
⚠️
todosis a constant within the component's render closure. If you calladdTodoanddeleteTodoconsecutively in the same event,todosinsidesetTodos([...todos, newTodo])might still be the old value from the previous frame. Functional updates guarantee thatprevis always React's internal latest snapshot.
Step 3: Components Are Reduced to Skeletons—Thin to Just return
With useTodos, components become pure "UI shells." First, look at TodoInput:
// components/TodoInput.tsx
import { useState } from 'react';
interface TodoInputProps {
onAdd: (text: string) => void; // 🔑 The component only defines "what events it can emit", not "what happens after the event"
}
export function TodoInput({ onAdd }: TodoInputProps) {
const [text, setText] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onAdd(text); // Hand off to Hook
setText(''); // Clear its own local state
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={text}
onChange={e => setText(e.target.value)}
placeholder="Enter a todo..."
/>
<button type="submit">Add</button>
</form>
);
}
Notice what this component does and does not do:
| Does (UI Responsibility) | Does Not Do (Logic Responsibility, handed to Hook) |
|---|---|
Manages the input's local text |
Doesn't care how todos are stored |
Calls onAdd(text) on submit |
Doesn't care how IDs are generated |
| Clears the input after submit | Doesn't care if the update is append or replace |
A component's props only define "what events it can emit" (onAdd: (text: string) => void), not "what happens after the event." This is the ultimate controlled component—render whatever props are given, shout (callback) when something happens, and ignore the rest.
TodoItem is the same, not a single line of its own state:
// components/TodoItem.tsx
interface TodoItemProps {
todo: Todo;
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}
export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<li className={todo.completed ? 'completed' : ''}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
);
}
There is no useState, no useEffect, no business logic judgment in the component. It is just a "pure render function."
Step 4: App.tsx Becomes an "Assembler"
All logic is in the Hook, all rendering is in the components. What does App do? Connect the Hook and the components:
// App.tsx ── Sole responsibility: Distribute the Hook's data and operations to components
import { useTodos } from './hooks/useTodos';
import { TodoInput } from './components/TodoInput';
import { TodoList } from './components/TodoList';
import { TodoFilter } from './components/TodoFilter';
function App() {
const { todos, filter, addTodo, toggleTodo, deleteTodo, clearCompleted } = useTodos();
return (
<div className="app">
<h1>Todo List</h1>
<TodoInput onAdd={addTodo} />
<TodoList
todos={todos}
filter={filter}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
<TodoFilter
current={filter}
onChange={setFilter}
onClearCompleted={clearCompleted}
/>
</div>
);
}
App.tsx went from a 300-line "all-powerful controller" to a 20-line "patch panel." This is the ultimate goal of custom Hooks:
graph LR
A["useTodos Hook"] -->|"Data + Methods"| B["App.tsx Assembly Layer"]
B -->|"props"| C["TodoInput"]
B -->|"props"| D["TodoList"]
B -->|"props"| E["TodoFilter"]
Architecture Comparison: Splitting Components vs Splitting Logic
A table summarizing the vast difference between the two organizational approaches:
| Dimension | Only Split Components (❌ Common Mistake) | Component + Hook Dual Split (✅ Correct) |
|---|---|---|
| App.tsx lines | 200-300 lines | 20-30 lines |
| Where is state | Scattered in App and various components | Centralized in useTodos |
| Adding a new operation | Find all related components and modify each | Just add one function in useTodos |
| Unit testing | Must mount the entire component | Just test the useTodos function alone |
| Swapping UI framework | Logic and JSX tangled, rewrite | Hook kept as-is, only swap components |
| Reuse | Copy-paste code blocks | Directly import { useTodos } |
Conclusion: Splitting components solves "looking messy," splitting Hooks solves "being tiring to change."
Three Common "Should This Go in a Hook?" Questions
Q1: Should filtering logic go in the Hook or the component?
Put it in the Hook. Filtering is "data derivation," which belongs to business logic. The component only cares about "getting the final data and rendering it."
// ✅ Do filtering in the Hook, only expose filteredTodos to the outside
export function useTodos() {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<Filter>('all');
// 🔑 Derived state: Don't store a second copy in state, calculate it before return
const filteredTodos = todos.filter(item => {
if (filter === 'all') return true;
if (filter === 'completed') return item.completed;
return !item.completed; // 'uncompleted'
});
return { todos: filteredTodos, filter, /* ... */ };
}
⚠️ Do not store
filteredTodosas anotheruseState. It is entirely computed fromtodos+filter. Storing two copies of data only brings synchronization bugs. This is "derived state"—if it can be calculated, don't store it.
Q2: Should the input field's local state go in the Hook or the component?
Put it in the component. The text inside TodoInput is "the input field's own business," other components don't care. The Hook only needs to know "what the user submitted," not "what the user typed."
Judgment criterion in one sentence: Only state that multiple components need to share goes into the Hook.
Q3: When should useState be upgraded to useReducer?
The current useTodos manages state with 4 separate functions, which already feels a bit "scattered." If more features are added—batch delete, undo, drag-and-drop sorting—each new feature requires adding a new setState operation function.
After upgrading to useReducer:
type TodoAction =
| { type: 'ADD'; text: string }
| { type: 'TOGGLE'; id: string }
| { type: 'DELETE'; id: string }
| { type: 'CLEAR_COMPLETED' };
function todoReducer(state: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case 'ADD':
return [...state, { id: Date.now().toString(), text: action.text, completed: false }];
case 'TOGGLE':
return state.map(item =>
item.id === action.id ? { ...item, completed: !item.completed } : item
);
case 'DELETE':
return state.filter(item => item.id !== action.id);
case 'CLEAR_COMPLETED':
return state.filter(item => !item.completed);
default:
return state;
}
}
Judging the upgrade timing: When a Hook has 3 or more mutually independent setState calls, and the operation logic is so much that "you can't remember all the operations at a glance," it's time to upgrade to useReducer.
Conclusion: Next time before writing a component, write the Hook first
The opening asked: Why, after splitting components, is App.tsx still 300 lines?
Because you split the UI, not the logic. True refactoring is: moving useState and business functions out of components and into custom Hooks. Components become pure "render functions," App becomes a "patch panel."
Remember in one sentence: Hooks are responsible for "how data changes," components are responsible for "how it looks," App is responsible for "who can see what." Three layers each manage their own, changing any one layer does not affect the other two.
Next time you write a React component, just do this one thing
- Before writing any JSX, first write all business logic into
use[Feature].ts - Components only receive data via props, notify events via callbacks—do not directly manipulate global state
- If a component has
useStateother than for input fields, ask yourself: "Is this state only used by this component?" No → Lift it into the Hook
Open question: Have you ever encountered a situation where "components are split very cleanly, but changing one feature still requires modifying three or four files"? Where do you think the problem lies—is the Hook split not granular enough, or is the dimension of component splitting wrong? Share your painful experiences in the comments.