Building a Scalable & Persistent Todo App with Anchor and React
Learn how to build a scalable, persistent todo app in React using Anchor as the State Management.
IMPORTANT
This tutorial is designed to help you build a scalable application that maintains consistent performance whether displaying 3 todos or 1,000 todos.
Note: Learning to build scalable applications from the start is crucial. Without this foundation, you'll face significant challenges when working on complex projects.
What You'll Learn?
In this tutorial, you will learn:
- State architecture - How to structure your state to build a scalable app.
- Logic distribution - How to distribute logic to the relevant components.
- Component splitting - How to split your components into smaller components.
- Selective rendering - How to render components based on certain conditions.
- Persistent Storage - How to persist your data using Anchor's storage library.
Application Structure
Let's start with creating the data structure that we will need for our Todo App.
Data (D in DSV)
To build a Todo App, we will need the following data:
Todo
id(string) - Unique identifier for each todo.text(string) - Text of the todo.completed(boolean) - Whether the todo is completed or not.
Todo List
todos(array) - Array of todos.
Todo Stats
total(number) - Total number of todos.completed(number) - Total number of completed todos.pending(number) - Total number of pending todos.
Why Todo Stats?
A scalable application should avoid relying on heavy computations to calculate statistics. This ensures that regardless of whether you have 3 or 1,000 todos, the app remains fast and responsive. To achieve this, we use a dedicated stats data structure to hold the statistics and update them in atomic operations.
Note: While this approach might seem more complex or even overkill at first, it's a best practice for building scalable applications. You'll appreciate this design decision as your app grows beyond just a few todos.
States (S in DSV)
Since we will persist the data, we will need a way to store the data. We will use Anchor's storage library to store the data.
todoTable(object) - Anchor's table that will bridge operation to store the data in IndexedDB.todoStats(object) - Anchor's KV (key-value) store that will bridge operation to store the data in IndexedDB.
Components (V in DSV)
TodoItem- Component that renders a single todo.TodoList- Component that renders a list of todos.TodoStats- Component that renders the stats.TodoForm- Component that renders the form to add a todo, or edit a todo.App- The main component that renders the app.
Implementation
Let's start by creating the data structure.
import '@tailwindcss/browser';
import { createRecord, createTable, type InferList, type InferRow, kv } from '@anchorlib/storage/db';
import { setDebugRenderer } from '@anchorlib/react';
setDebugRenderer(true, 300);
export type Todo = {
text: string;
completed: boolean;
};
// Create a table for todos.
export const todoTable = createTable<Todo>('todos');
// Seed the table with initial data.
todoTable.seed([
createRecord({ text: 'Learn React', completed: true }),
createRecord({ text: 'Learn Anchor', completed: false }),
]);
// Type aliases for convenience.
export type TodoRec = InferRow<typeof todoTable>;
export type TodoRecList = InferList<typeof todoTable>;
// Create a key-value store for todo stats.
export const todoStats = kv('todo-stats', {
total: 2,
pending: 1,
completed: 1,
});What we have done here?
- ✓ We created a table for todos.
- ✓ We seeded the table with initial data.
- ✓ We created a key-value store for todo stats.
Then let's create the mutators, a set of handlers that will be used to manage the todos and stats.
import type { Row, RowListState } from '@anchorlib/storage/db';
import { type Todo, type TodoRec, todoStats, todoTable } from './todos';
export const todoActions = {
// Handle adding a new todo item.
add(list: RowListState<Row<Todo>>, text: string) {
// Create new todo item.
const newItem = todoTable.add({ text, completed: false });
// Push the new todo to the list.
list.data.push(newItem.data);
// Update the stats.
todoStats.data.total++;
todoStats.data.pending++;
},
// Handle toggling a todo item.
toggle(todo: TodoRec) {
const stats = todoStats.data;
// Toggle the completed state of the todo.
todo.completed = !todo.completed;
// Update the stats.
if (todo.completed) {
stats.completed++;
stats.pending--;
} else {
stats.completed--;
stats.pending++;
}
},
// Handle removing a todo item.
remove(todo: TodoRec) {
const stats = todoStats.data;
// Delete the todo.
todoTable.remove(todo.id);
// Update the total count.
stats.total--;
// Update the completed count.
if (todo.completed) {
stats.completed--;
} else {
stats.pending--;
}
},
};What we have done here?
- ✓ We created a set of handlers that will be used to manage the todos.
- ✓ We created a set of handlers that will be used to manage the stats.
Now let's create the components. Let's start with the TodoItem component.
import { memo, useRef } from 'react';
import { type TodoRec } from './todos';
import { debugRender, useObservedRef, type VariableRef, view } from '@anchorlib/react';
import { todoActions } from './actions';
import { getContext } from '@anchorlib/core';
// Create a component that renders a single todo. This component is rendered once for each todo.
function TodoItem({ todo }: { todo: TodoRec }) {
const itemRef = useRef(null);
const handleToggle = () => {
// Call the action to toggle the todo.
todoActions.toggle(todo);
};
const handleDelete = () => {
// Call the action to delete the todo.
todoActions.remove(todo);
};
// Create a view for the todo item, since only these elements that need to be re-rendered.
const TodoItemView = view(() => {
return (
<div className="flex items-center gap-2 flex-1">
<input
type="checkbox"
checked={todo.completed}
className="h-5 w-5 rounded text-blue-600 focus:ring-blue-500"
onChange={handleToggle}
/>
<input
type="text"
value={todo.text}
onChange={(e) => (todo.text = e.target.value)}
className={`ml-3 flex-1 text-gray-700 dark:text-slate-200 outline-none ${todo.completed ? 'line-through' : ''}`}
/>
</div>
);
});
// Get the search variable from the global context.
const search = getContext('search') as VariableRef<string>;
// Observe the search variable to know when to hide the todo item.
const shouldHide = useObservedRef(() => {
// Make sure to check if the search variable is defined and have a value.
if (!search?.value) return false;
// Mark the todo as hidden if there is an active search and the todo is not matching the search query.
return !todo.text.toLowerCase().includes(search.value.toLowerCase());
});
// This prevents the TodoList component to re-render.
const TodoItemBody = view(() => {
// Remove itself if the hidden variable is true.
// This only re-renders when the hidden variable changes.
if (shouldHide.value) return;
// Remove itself if the todo is deleted.
if (todo.deleted_at) return;
debugRender(itemRef);
return (
<li
ref={itemRef}
className="flex items-center p-4 gap-4 transition duration-150 hover:bg-gray-100 dark:hover:bg-slate-900">
<TodoItemView />
<button
className="ml-2 rounded px-2 py-1 text-xs text-red-600 opacity-80 transition duration-200 hover:opacity-100 dark:text-slate-300 uppercase"
onClick={handleDelete}>
Delete
</button>
</li>
);
});
return <TodoItemBody />;
}
// Memoize the component to prevent unnecessary re-renders.
export default memo(TodoItem);What we have done here?
- ✓ We created a component that renders a single todo.
- ✓ We created a view for the todo item, since only these elements that need to be re-rendered.
- ✓ We created a toggle handler that handles toggling the completed status and update the stats.
- ✓ We created a delete handler that handles deleting a todo and update the stats.
- ✓ We created an edit handler that handles editing the todo text with a simple syntax.
- ✓ We created an observer using
useObservedRef()that observes the search term to hide itself if the text doesn't match the search term. It re-renders only when the variable is actually changes (falsetotrueortruetofalse). - ✓ We only display the todo item if it's not deleted by checking the
deleted_atproperty. - ✓ We used
view()HOC to selectively render partial elements. - ✓ We memoized the component to prevent re-render when the todo list is updated.
import { type Todo } from './todos';
import TodoItem from './TodoItem';
import type { Row, RowListState } from '@anchorlib/storage/db';
import { useRef } from 'react';
import { debugRender, useObserver } from '@anchorlib/react';
// This component is expected to re-render when todo list changes,
// as its purpose is to only render todo items.
function TodoList({ todos }: { todos: RowListState<Row<Todo>> }) {
const ref = useRef(null);
debugRender(ref);
// Observe the status and total count of todos since only those data that relevant
// for rendering the list.
const [status] = useObserver(() => [todos.status, todos.data.length]);
if (status === 'pending') {
return <p className="text-slate-400 text-sm flex items-center justify-center mt-4">Loading...</p>;
}
return (
<ul
ref={ref}
className="todo-list divide-y divide-gray-200 rounded-lg bg-gray-50 dark:divide-slate-600 dark:bg-slate-700">
{todos.data.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
export default TodoList;What we have done here?
- ✓ We created a view to render the list of todos.
- ✓ We used
useObserver()hook to observe the query status. - ✓ We used
useObserver()hook to observe the todo list length. This make sure the observer is reacting topushevent.
import { todoStats } from './todos';
import { useRef } from 'react';
import { debugRender, observer } from '@anchorlib/react';
// We don't need selective rendering here because the component is small.
function TodoStats() {
const ref = useRef(null);
debugRender(ref);
const stats = todoStats.data;
return (
<div
ref={ref}
className="todo-stats mt-4 flex items-center justify-between rounded-lg border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-800">
<div className="todo-stats-item flex flex-col items-center">
<span className="todo-stats-label text-sm text-gray-500 dark:text-slate-400">Total</span>
<span className="todo-stats-value text-lg font-semibold dark:text-white">{stats.total}</span>
</div>
<div className="todo-stats-item flex flex-col items-center">
<span className="todo-stats-label text-sm text-gray-500 dark:text-slate-400">Completed</span>
<span className="todo-stats-value text-lg font-semibold dark:text-white">{stats.completed}</span>
</div>
<div className="todo-stats-item flex flex-col items-center">
<span className="todo-stats-label text-sm text-gray-500 dark:text-slate-400">Pending</span>
<span className="todo-stats-value text-lg font-semibold dark:text-white">{stats.pending}</span>
</div>
</div>
);
}
export default observer(TodoStats);What we have done here?
- ✓ We created a view to render the stats.
- ✓ We used
observer()HOC to observe the stats.
import { debugRender, observer, useVariable } from '@anchorlib/react';
import { type FormEventHandler, useRef } from 'react';
import { type Todo } from './todos';
import type { Row, RowListState } from '@anchorlib/storage/db';
import { todoActions } from './actions';
function TodoForm({ todos }: { todos: RowListState<Row<Todo>> }) {
const ref = useRef(null);
debugRender(ref);
const [text] = useVariable('');
const handleSubmit: FormEventHandler = (e) => {
e.preventDefault();
// Add the todo.
todoActions.add(todos, text.value);
// Reset the form input.
text.value = '';
};
return (
<form ref={ref} className="my-6 flex gap-2" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Add a new todo"
value={text.value}
onChange={(e) => (text.value = e.target.value)}
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-white"
/>
<button
type="submit"
disabled={!text.value}
className="rounded-lg bg-blue-500 px-4 py-2 font-bold text-white transition duration-200 hover:bg-blue-700 disabled:opacity-50 disabled:pointer-events-none">
Add
</button>
</form>
);
}
export default observer(TodoForm);What we have done here?
- ✓ We created a view to render the form to add a todo.
- ✓ We used
observer()HOC to observe the form. - ✓ We created a submit handler that adds a todo to the list and update the stats.
import { useTableList } from '@anchorlib/react/storage';
import { type Todo, todoTable } from './todos';
import TodoForm from './TodoForm';
import TodoList from './TodoList';
import TodoStats from './TodoStats';
import { useRef } from 'react';
import { debugRender, useVariable, view } from '@anchorlib/react';
import { setContext } from '@anchorlib/core';
export default function App() {
const ref = useRef(null);
debugRender(ref);
const [todos] = useTableList<Todo, typeof todoTable>(todoTable);
const [search] = useVariable('');
// Register the search variable to the global context.
setContext('search', search);
// Create a view for the search input. Only re-rendered when the search value changes.
const Search = view(() => {
return (
<input
type="text"
placeholder="Search..."
value={search.value}
onChange={(e) => (search.value = e.target.value)}
className="w-full mb-6 rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-white"
/>
);
});
return (
<div
ref={ref}
className="my-10 w-full max-w-md mx-auto overflow-hidden rounded-xl border border-slate-200 bg-white p-10 dark:border-slate-700 dark:bg-slate-800">
<div className="mb-10 flex flex-col items-center justify-center">
<img src="https://anchorlib.dev/docs/icon.svg" alt="Anchor Logo" className="mb-4 w-16" />
<h1 className="text-3xl font-medium text-gray-800 dark:text-white">Todo App</h1>
</div>
<Search />
<TodoList todos={todos} />
<TodoForm todos={todos} />
<TodoStats />
</div>
);
}What we have done here?
- ✓ We created a view to render the app.
- ✓ We don't use any observation due to the component is not displaying any data directly.
- ✓ We created a state to query the list of todos.
- ✓ We created a search state and register it to the global context.
- ✓ We created a simple search input that only re-render itself.
Preview
After we have created the components, let's put them together in the app.
Conclusion
In this tutorial, we have learned how to build a Todo App using React and Anchor. We have learned how to structure our state, component, and selectively render components. We have also learned how to persist our data using Anchor's storage library.
Optimization Achievements
- ✓ Utilized
view()HOC for selective rendering of partial elements, enhancing performance. - ✓ Employed
view()HOC to conditionally display/hide todo items based on thedeleted_atproperty. This significantly reduces computational overhead for list filtering and prevents unnecessary re-renders of the parent component when an item is deleted. - ✓ We reduced the heavy computation to calculate the stats by using a dedicated
statsdata. - ✓ We used
memo()HOC to prevent re-render todo item when the todo list is updated. - ✓ No boilerplate code. Those
debugRendercode is simply a debug tool for this tutorial, so we can see which component that is being re-rendered.
Logic Distribution
- The App job is only to query the list of todos and distribute it to the components that need it.
- The TodoForm is responsible for adding new todos and updating the statistics.
- The TodoItem handles updating a todo's status (e.g.,
completedordeleted) and subsequently updating the statistics.
Why distribute the logic?
In traditional React applications, all logic is typically handled in the parent component (centralized approach). As the application grows, this makes maintenance increasingly difficult because the parent component accumulates too much responsibility. It's like being a boss who hires workers but ends up doing all the work yourself, while your team just tells you what needs to be done.
The decentralized approach distributes logic to the components that actually need it. This makes the application much easier to maintain, as each component is responsible for its own specific logic. Think of it as being a boss who hires skilled workers and trusts them to do their respective jobs effectively.
Render Performance
- The App component is rendered only once.
- The TodoForm component re-renders exclusively when there's activity within the form (e.g., typing, submission).
- The TodoList component re-renders only when a new item is added.
- The TodoStats component re-renders solely when there's a change in its underlying state.
- The TodoItem component has two types of rendering:
- The TodoItemView re-renders when the todo's status (
completed) changes. - The TodoItemBody re-renders only when the deletion status (
deleted_at) changes. - The TodoItem component itself renders only once, ensuring that all its internal functions and state are created just once and remain stable throughout its lifecycle.
- The TodoItemView re-renders when the todo's status (