A React Todo App as a Blueprint for Architecture-First Thinking
A Todo application may only have 10 files, but its architectural thinking can be scaled to projects of any size.
I. The Summit: What This Project Does in One Sentence
This project builds a to-do application using a three-layer separation of "type definitions → state logic → UI components." The core innovation is extracting all business logic into a custom Hook (useTodos), so components are only responsible for "drawing the UI," not "thinking about the logic."
If you take away only one sentence, it's the one above. Below, we'll unpack it layer by layer.
II. The Mountainside: Four Architectural Layers, Supporting Each Other
For the second level of the pyramid, I break it down into four logical groups, each answering one question.
Group 1: The Foundation — The Type System (src/types/todo.ts)
Analogy: Draw the blueprints before building the house. Before writing any UI code, this project first defines "what a Todo looks like."
export interface Todo {
id: string;
text: string;
completed: boolean;
}
export type FilterType = 'all' | 'completed' | 'uncompleted';
There are three design decisions here worth savoring:
- Using
interfacerather thantypeto define Todo — The comments note that "interface cannot apply for simple data types," but the deeper reason is:interfacedescribes the "structure of an object," making it semantically more suitable for defining an entity;typeis more suitable for union types (likeFilterType). This is an embodiment of "using the right tool for the right job" in TypeScript. idusesstringrather thannumber— BecauseDate.now().toString()is used later to generate IDs. This choice sacrifices a bit of performance (string comparison is slower than number comparison) in exchange for a guarantee of uniqueness (timestamp + type conversion), which is perfectly reasonable for a Todo-scale scenario.FilterTypeuses a literal union type —'all' | 'completed' | 'uncompleted'. This means if you writesetFilter('archived')in your code, TypeScript will directly throw an error, stopping the bug at the compilation stage. This is a classic practice of "letting the compiler check your logic for you."
Food for thought: Many beginners skip type definitions and jump straight to writing components. The result is a
todoobject scattered across 5 files, each with different assumptions about it. A type file is about writing down the "consensus" in advance, so the team (or even your future self) doesn't have to guess.
Group 2: The Brain — The Custom Hook (src/hooks/useTodos.ts)
Analogy: If components are the body's organs, the Hook is the brain. Organs are responsible for execution (displaying UI), and the brain is responsible for decision-making (managing state and logic).
export function useTodos() {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<FilterType>('all');
// ...addTodo, toggleTodo, deleteTodo, clearCompleted
return { todos, filter, addTodo, toggleTodo, deleteTodo, clearCompleted };
}
This Hook's design has four points worth diving into:
2.1 The "Immutable Pattern" of State Updates
Every operation uses functional updates (setTodos(prev => ...)):
const toggleTodo = (id: string) => {
setTodos(prev =>
prev.map(item =>
item.id === id ? { ...item, completed: !item.completed } : item
)
);
};
Why not just item.completed = !item.completed? Because React's rendering mechanism relies on reference comparison (prevTodos !== newTodos). If you directly mutate the original object, React will say, "Hmm, the reference is still the same, no need to re-render," and your UI won't update. This is the core reason for React's immutability — not a philosophical preference, but a technical constraint of the rendering mechanism.
A beginner's intuition is "can't I just change it?" but it's precisely this intuition that causes the most React bugs.
2.2 The filter State is Defined but Not Yet Used
filter exists, and FilterType is defined, but useTodos doesn't filter todos based on the filter. The commented-out filteredTodos function in the notes shows the author is aware of this gap:
// const filteredTodos = () => {
// }
This is an excellent teaching moment: The Hook exposes filter but doesn't expose the filtered results, meaning the filtering logic is either still being designed or intended to be done at the component layer. Both choices have pros and cons — putting it in the Hook is more cohesive, putting it in the component is more flexible. This blank space perfectly illustrates the trade-offs in architectural decision-making.
2.3 clearCompleted is a Batch Operation
const clearCompleted = () => {
setTodos(prev => prev.filter(item => !item.completed));
};
Note that it uses filter rather than deleting one by one. This is declarative programming thinking: instead of saying "delete the 2nd, delete the 5th," you say "I want a list without completed items." Declarative code is less error-prone because you don't need to manually maintain index offset issues like "after deleting the 2nd, what position did the original 5th become?"
2.4 The Return Value is an Object, Not an Array
return { todos, filter, addTodo, toggleTodo, deleteTodo, clearCompleted };
Compared to return [todos, filter, addTodo, ...], the benefit of object destructuring is that you can pick what you need when using it, and the names are fixed:
const { addTodo, toggleTodo } = useTodos(); // ✅ Order doesn't matter, clear at a glance
const [todos, , addTodo] = useTodos(); // ❌ Need to remember positions, hard to maintain
Group 3: The Body — The Component Layer (src/component/)
The four component files are currently shells: Todoinput.tsx, Todoitem.tsx, TodoList.tsx, TodoFilter.tsx.
This is precisely evidence of an architecture-first working style. Before writing any specific UI, the author has already clarified, "I need four components, responsible for input, displaying a single item, displaying the list, and filtering." This is like erecting the walls of a house before building it — although it's not decorated yet, the spatial division is already clear.
From the naming, we can infer the component tree structure:
App
├── TodoInput (Input box + Add button)
├── TodoFilter (Three filter options: All/Completed/Uncompleted)
└── TodoList (List container)
└── TodoItem × N (Each todo)
This is the embryonic form of a classic container-presentation component pattern: TodoList is the container, TodoItem is purely presentational.
Group 4: The Entry — The Application Assembly Layer (App.tsx + main.tsx)
main.tsx is the ignition switch for the entire application:
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
StrictMode in development mode will intentionally call certain functions twice (like useState's initializer function, useEffect) to help expose problems with "impure side effects." Many beginners see console.log printed twice and think it's a bug, but it's actually React doing a stress test for you.
App.tsx is currently still Vite's template page, which means the author is at the stage of "infrastructure complete, ready to assemble."
III. The Foot of the Mountain: A Panoramic View of Data Flow
Stringing the four layers together, how does data flow? Describe it in one sentence:
The user types in
TodoInput→ calls the Hook'saddTodo→ inside the Hook,setTodosupdates the state → React detects the state change → re-rendersTodoList→ eachTodoItemreceives the latest data.
This forms a closed loop of unidirectional data flow:
User Action → Component Event → Hook Method → setState → Component Re-render → New UI
Why is unidirectional data flow important? Because it makes bugs traceable. If data could flow bidirectionally (a child component directly modifying a parent component's state), when a problem like "how did this todo get mysteriously deleted" arises, you'd need to investigate N possible places that could have modified it. Unidirectional flow means you just need to trace backward along the arrow, and you can always find the source.
A Metaphor to Tie the Whole Article Together
Imagine you are in a restaurant:
| Role | Corresponds to | Responsibility |
|---|---|---|
| Recipe (Type Defs) | types/todo.ts |
Defines what each dish looks like and what attributes it has |
| Head Chef (Hook) | hooks/useTodos.ts |
Holds the state of all orders, decides the logic for CRUD |
| Waiter (Components) | component/*.tsx |
Receives customer instructions, passes them to the kitchen, then presents the results to the customer |
| Restaurant Manager (App.tsx) | App.tsx |
Assigns waiters to their positions, coordinates the entire process |
| Restaurant Door (main.tsx) | main.tsx |
The entrance for the customer (user) |
The customer doesn't need to know how the kitchen operates; they just talk to the waiter. The waiter doesn't need to know how to cook; they just pass the request to the head chef. The head chef doesn't need to know which table the dish goes to; they just maintain the state of the orders. Each layer only minds its own business — this is the essence of layered architecture.
IV. Follow-up Suggestions for This Project
As an added value of code review, if you want to continue improving this project:
- Implement
filteredTodosin the Hook — Return a filtered list based on thefilterstate, keeping filtering logic and state together. - Inject the Hook into components — Call
useTodos()inApp.tsxand distribute the return values to child components via props. - Consider
useReducerinstead ofuseState— When the types of operations increase (add, toggle, delete, clear, edit),useReducercentralizes state change logic into a single reducer function, making it easier to maintain than scatteredsetXxxcalls. - Add localStorage persistence — Data loss on refresh is a classic pain point for Todo apps; you can add
useEffectin the Hook for synchronization.
Summary: Good architecture is not "refactored out after writing a lot of code," but rather, before writing the first line of code, you first think through three questions: "Where do the type definitions go? Where does the state go? How are the components split?" This Todo project is a great demonstration — not many files, but each layer has clear responsibilities and doesn't overstep its bounds.