diff --git a/README.md b/README.md
index d379920..9773871 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ A modern, full-featured todo application built with React Router 7, showcasing b
- **Tailwind CSS v4** - Modern styling with CSS variables
- **shadcn/ui Components** - Beautiful, accessible UI components
- **TypeScript** - Full type safety throughout
-- **Zustand** - Lightweight state management
+- **React Context + useReducer** - Built-in state management
- **Vitest** - Fast unit testing
- **Bun** - Fast package manager and runtime
- **Biome** - Fast linting and formatting
@@ -82,11 +82,12 @@ This project uses a monorepo structure with the following packages:
- **@todo-starter/utils** - Shared utilities, types, and helpers
### State Management
-The app uses Zustand for state management with the following features:
+The app uses React's built-in Context API with useReducer for state management with the following features:
- In-memory todo storage
- CRUD operations for todos
- Filtering (all, active, completed)
- Bulk operations (clear completed)
+- Type-safe actions and state updates
### Component Architecture
Components are organized by feature and follow these principles:
@@ -181,7 +182,7 @@ The app supports:
- [React Router 7 Documentation](https://reactrouter.com/)
- [Tailwind CSS v4](https://tailwindcss.com/)
- [shadcn/ui](https://ui.shadcn.com/)
-- [Zustand](https://zustand-demo.pmnd.rs/)
+- [React Context](https://react.dev/reference/react/useContext)
- [Vitest](https://vitest.dev/)
- [Turbo](https://turbo.build/)
diff --git a/apps/todo-app/app/lib/__tests__/todo-context.test.tsx b/apps/todo-app/app/lib/__tests__/todo-context.test.tsx
new file mode 100644
index 0000000..b07b388
--- /dev/null
+++ b/apps/todo-app/app/lib/__tests__/todo-context.test.tsx
@@ -0,0 +1,209 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen, act } from '@testing-library/react';
+import { TodoProvider, useTodoStore, getFilteredTodos } from '../todo-context';
+import type { Todo } from '@todo-starter/utils';
+
+// Mock crypto.randomUUID for consistent testing
+Object.defineProperty(global, 'crypto', {
+ value: {
+ randomUUID: () => 'test-uuid'
+ }
+});
+
+// Test component to access the context
+function TestComponent() {
+ const {
+ todos,
+ filter,
+ addTodo,
+ toggleTodo,
+ deleteTodo,
+ updateTodo,
+ setFilter,
+ clearCompleted
+ } = useTodoStore();
+
+ return (
+
+
{todos.length}
+
{filter}
+
+
+
+
+
+
+ {todos.map(todo => (
+
+ {todo.text} - {todo.completed ? 'completed' : 'active'}
+
+ ))}
+
+ );
+}
+
+function renderWithProvider() {
+ return render(
+
+
+
+ );
+}
+
+describe('todo-context', () => {
+ describe('TodoProvider and useTodoStore', () => {
+ it('provides initial todos', () => {
+ renderWithProvider();
+
+ expect(screen.getByTestId('todos-count')).toHaveTextContent('3');
+ expect(screen.getByTestId('filter')).toHaveTextContent('all');
+ });
+
+ it('adds a new todo', () => {
+ renderWithProvider();
+
+ act(() => {
+ screen.getByTestId('add-todo').click();
+ });
+
+ expect(screen.getByTestId('todos-count')).toHaveTextContent('4');
+ expect(screen.getByTestId('todo-test-uuid')).toHaveTextContent('New todo - active');
+ });
+
+ it('toggles todo completion status', () => {
+ renderWithProvider();
+
+ // First todo should be active initially
+ expect(screen.getByTestId('todo-1')).toHaveTextContent('Learn React Router 7 - active');
+
+ act(() => {
+ screen.getByTestId('toggle-todo').click();
+ });
+
+ expect(screen.getByTestId('todo-1')).toHaveTextContent('Learn React Router 7 - completed');
+ });
+
+ it('deletes a todo', () => {
+ renderWithProvider();
+
+ expect(screen.getByTestId('todos-count')).toHaveTextContent('3');
+
+ act(() => {
+ screen.getByTestId('delete-todo').click();
+ });
+
+ expect(screen.getByTestId('todos-count')).toHaveTextContent('2');
+ expect(screen.queryByTestId('todo-1')).not.toBeInTheDocument();
+ });
+
+ it('updates todo text', () => {
+ renderWithProvider();
+
+ expect(screen.getByTestId('todo-1')).toHaveTextContent('Learn React Router 7 - active');
+
+ act(() => {
+ screen.getByTestId('update-todo').click();
+ });
+
+ expect(screen.getByTestId('todo-1')).toHaveTextContent('Updated text - active');
+ });
+
+ it('sets filter', () => {
+ renderWithProvider();
+
+ expect(screen.getByTestId('filter')).toHaveTextContent('all');
+
+ act(() => {
+ screen.getByTestId('set-filter').click();
+ });
+
+ expect(screen.getByTestId('filter')).toHaveTextContent('active');
+ });
+
+ it('clears completed todos', () => {
+ renderWithProvider();
+
+ // Toggle first todo to completed
+ act(() => {
+ screen.getByTestId('toggle-todo').click();
+ });
+
+ expect(screen.getByTestId('todos-count')).toHaveTextContent('3');
+
+ act(() => {
+ screen.getByTestId('clear-completed').click();
+ });
+
+ expect(screen.getByTestId('todos-count')).toHaveTextContent('2');
+ });
+
+ it('throws error when used outside provider', () => {
+ // Suppress console.error for this test
+ const originalError = console.error;
+ console.error = () => {};
+
+ expect(() => {
+ render();
+ }).toThrow('useTodoStore must be used within a TodoProvider');
+
+ console.error = originalError;
+ });
+ });
+
+ describe('getFilteredTodos', () => {
+ const mockTodos: Todo[] = [
+ {
+ id: '1',
+ text: 'Active todo',
+ completed: false,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ },
+ {
+ id: '2',
+ text: 'Completed todo',
+ completed: true,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ }
+ ];
+
+ it('returns all todos when filter is "all"', () => {
+ const filtered = getFilteredTodos(mockTodos, 'all');
+ expect(filtered).toHaveLength(2);
+ });
+
+ it('returns only active todos when filter is "active"', () => {
+ const filtered = getFilteredTodos(mockTodos, 'active');
+ expect(filtered).toHaveLength(1);
+ expect(filtered[0].completed).toBe(false);
+ });
+
+ it('returns only completed todos when filter is "completed"', () => {
+ const filtered = getFilteredTodos(mockTodos, 'completed');
+ expect(filtered).toHaveLength(1);
+ expect(filtered[0].completed).toBe(true);
+ });
+ });
+});
diff --git a/apps/todo-app/app/lib/__tests__/todo-store.test.ts b/apps/todo-app/app/lib/__tests__/todo-store.test.ts
deleted file mode 100644
index 76970ba..0000000
--- a/apps/todo-app/app/lib/__tests__/todo-store.test.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { describe, it, expect, beforeEach } from 'vitest';
-import { useTodoStore, getFilteredTodos } from '../todo-store';
-import type { Todo } from '@todo-starter/utils';
-
-// Mock crypto.randomUUID for consistent testing
-Object.defineProperty(global, 'crypto', {
- value: {
- randomUUID: () => 'test-uuid'
- }
-});
-
-describe('todo-store', () => {
- beforeEach(() => {
- // Reset store state before each test
- useTodoStore.setState({
- todos: [],
- filter: 'all'
- });
- });
-
- describe('addTodo', () => {
- it('adds a new todo', () => {
- const { addTodo } = useTodoStore.getState();
-
- addTodo('Test todo');
-
- const { todos } = useTodoStore.getState();
- expect(todos).toHaveLength(1);
- expect(todos[0].text).toBe('Test todo');
- expect(todos[0].completed).toBe(false);
- expect(todos[0].id).toBe('test-uuid');
- });
-
- it('trims whitespace from todo text', () => {
- const { addTodo } = useTodoStore.getState();
-
- addTodo(' Test todo ');
-
- const { todos } = useTodoStore.getState();
- expect(todos[0].text).toBe('Test todo');
- });
- });
-
- describe('toggleTodo', () => {
- it('toggles todo completion status', () => {
- const { addTodo, toggleTodo } = useTodoStore.getState();
-
- addTodo('Test todo');
- const todoId = useTodoStore.getState().todos[0].id;
-
- toggleTodo(todoId);
-
- const { todos } = useTodoStore.getState();
- expect(todos[0].completed).toBe(true);
-
- toggleTodo(todoId);
- expect(useTodoStore.getState().todos[0].completed).toBe(false);
- });
- });
-
- describe('deleteTodo', () => {
- it('removes todo from list', () => {
- const { addTodo, deleteTodo } = useTodoStore.getState();
-
- addTodo('Test todo');
- const todoId = useTodoStore.getState().todos[0].id;
-
- deleteTodo(todoId);
-
- const { todos } = useTodoStore.getState();
- expect(todos).toHaveLength(0);
- });
- });
-
- describe('updateTodo', () => {
- it('updates todo text', () => {
- const { addTodo, updateTodo } = useTodoStore.getState();
-
- addTodo('Original text');
- const todoId = useTodoStore.getState().todos[0].id;
-
- updateTodo(todoId, 'Updated text');
-
- const { todos } = useTodoStore.getState();
- expect(todos[0].text).toBe('Updated text');
- });
- });
-
- describe('clearCompleted', () => {
- it('removes all completed todos', () => {
- const { addTodo, toggleTodo, clearCompleted } = useTodoStore.getState();
-
- addTodo('Todo 1');
- addTodo('Todo 2');
- addTodo('Todo 3');
-
- const todos = useTodoStore.getState().todos;
- toggleTodo(todos[0].id);
- toggleTodo(todos[2].id);
-
- clearCompleted();
-
- const remainingTodos = useTodoStore.getState().todos;
- expect(remainingTodos).toHaveLength(1);
- expect(remainingTodos[0].text).toBe('Todo 2');
- });
- });
-
- describe('getFilteredTodos', () => {
- const mockTodos: Todo[] = [
- {
- id: '1',
- text: 'Active todo',
- completed: false,
- createdAt: new Date(),
- updatedAt: new Date()
- },
- {
- id: '2',
- text: 'Completed todo',
- completed: true,
- createdAt: new Date(),
- updatedAt: new Date()
- }
- ];
-
- it('returns all todos when filter is "all"', () => {
- const filtered = getFilteredTodos(mockTodos, 'all');
- expect(filtered).toHaveLength(2);
- });
-
- it('returns only active todos when filter is "active"', () => {
- const filtered = getFilteredTodos(mockTodos, 'active');
- expect(filtered).toHaveLength(1);
- expect(filtered[0].completed).toBe(false);
- });
-
- it('returns only completed todos when filter is "completed"', () => {
- const filtered = getFilteredTodos(mockTodos, 'completed');
- expect(filtered).toHaveLength(1);
- expect(filtered[0].completed).toBe(true);
- });
- });
-});
-
diff --git a/apps/todo-app/app/lib/todo-context.tsx b/apps/todo-app/app/lib/todo-context.tsx
new file mode 100644
index 0000000..144ee1b
--- /dev/null
+++ b/apps/todo-app/app/lib/todo-context.tsx
@@ -0,0 +1,155 @@
+import { createContext, useContext, useReducer, type ReactNode } from 'react';
+import type { Todo, TodoFilter, TodoStore } from '@todo-starter/utils';
+
+// Define the action types for the reducer
+type TodoAction =
+ | { type: 'ADD_TODO'; payload: string }
+ | { type: 'TOGGLE_TODO'; payload: string }
+ | { type: 'DELETE_TODO'; payload: string }
+ | { type: 'UPDATE_TODO'; payload: { id: string; text: string } }
+ | { type: 'SET_FILTER'; payload: TodoFilter }
+ | { type: 'CLEAR_COMPLETED' };
+
+// Define the state interface
+interface TodoState {
+ todos: Todo[];
+ filter: TodoFilter;
+}
+
+// Initial state
+const initialState: TodoState = {
+ todos: [
+ {
+ id: '1',
+ text: 'Learn React Router 7',
+ completed: false,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ },
+ {
+ id: '2',
+ text: 'Set up Tailwind CSS',
+ completed: true,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ },
+ {
+ id: '3',
+ text: 'Build a todo app',
+ completed: false,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ }
+ ],
+ filter: 'all'
+};
+
+// Reducer function
+function todoReducer(state: TodoState, action: TodoAction): TodoState {
+ switch (action.type) {
+ case 'ADD_TODO': {
+ const newTodo: Todo = {
+ id: crypto.randomUUID(),
+ text: action.payload.trim(),
+ completed: false,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ };
+ return {
+ ...state,
+ todos: [...state.todos, newTodo]
+ };
+ }
+ case 'TOGGLE_TODO':
+ return {
+ ...state,
+ todos: state.todos.map(todo =>
+ todo.id === action.payload
+ ? { ...todo, completed: !todo.completed, updatedAt: new Date() }
+ : todo
+ )
+ };
+ case 'DELETE_TODO':
+ return {
+ ...state,
+ todos: state.todos.filter(todo => todo.id !== action.payload)
+ };
+ case 'UPDATE_TODO':
+ return {
+ ...state,
+ todos: state.todos.map(todo =>
+ todo.id === action.payload.id
+ ? { ...todo, text: action.payload.text.trim(), updatedAt: new Date() }
+ : todo
+ )
+ };
+ case 'SET_FILTER':
+ return {
+ ...state,
+ filter: action.payload
+ };
+ case 'CLEAR_COMPLETED':
+ return {
+ ...state,
+ todos: state.todos.filter(todo => !todo.completed)
+ };
+ default:
+ return state;
+ }
+}
+
+// Context type that includes both state and actions
+type TodoContextType = TodoState & {
+ addTodo: (text: string) => void;
+ toggleTodo: (id: string) => void;
+ deleteTodo: (id: string) => void;
+ updateTodo: (id: string, text: string) => void;
+ setFilter: (filter: TodoFilter) => void;
+ clearCompleted: () => void;
+};
+
+// Create the context
+const TodoContext = createContext(undefined);
+
+// Provider component
+export function TodoProvider({ children }: { children: ReactNode }) {
+ const [state, dispatch] = useReducer(todoReducer, initialState);
+
+ const contextValue: TodoContextType = {
+ ...state,
+ addTodo: (text: string) => dispatch({ type: 'ADD_TODO', payload: text }),
+ toggleTodo: (id: string) => dispatch({ type: 'TOGGLE_TODO', payload: id }),
+ deleteTodo: (id: string) => dispatch({ type: 'DELETE_TODO', payload: id }),
+ updateTodo: (id: string, text: string) =>
+ dispatch({ type: 'UPDATE_TODO', payload: { id, text } }),
+ setFilter: (filter: TodoFilter) => dispatch({ type: 'SET_FILTER', payload: filter }),
+ clearCompleted: () => dispatch({ type: 'CLEAR_COMPLETED' })
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+// Custom hook to use the todo context
+export function useTodoStore(): TodoContextType {
+ const context = useContext(TodoContext);
+ if (context === undefined) {
+ throw new Error('useTodoStore must be used within a TodoProvider');
+ }
+ return context;
+}
+
+// Helper function for filtering todos (keeping the same API)
+export const getFilteredTodos = (todos: Todo[], filter: TodoFilter): Todo[] => {
+ switch (filter) {
+ case 'active':
+ return todos.filter(todo => !todo.completed);
+ case 'completed':
+ return todos.filter(todo => todo.completed);
+ default:
+ return todos;
+ }
+};
diff --git a/apps/todo-app/app/lib/todo-store.ts b/apps/todo-app/app/lib/todo-store.ts
deleted file mode 100644
index 613d7cc..0000000
--- a/apps/todo-app/app/lib/todo-store.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import { create } from 'zustand';
-import type { Todo, TodoFilter, TodoStore } from '@todo-starter/utils';
-
-export const useTodoStore = create((set, get) => ({
- todos: [
- {
- id: '1',
- text: 'Learn React Router 7',
- completed: false,
- createdAt: new Date(),
- updatedAt: new Date()
- },
- {
- id: '2',
- text: 'Set up Tailwind CSS',
- completed: true,
- createdAt: new Date(),
- updatedAt: new Date()
- },
- {
- id: '3',
- text: 'Build a todo app',
- completed: false,
- createdAt: new Date(),
- updatedAt: new Date()
- }
- ],
- filter: 'all',
-
- addTodo: (text: string) => {
- const newTodo: Todo = {
- id: crypto.randomUUID(),
- text: text.trim(),
- completed: false,
- createdAt: new Date(),
- updatedAt: new Date()
- };
- set(state => ({
- todos: [...state.todos, newTodo]
- }));
- },
-
- toggleTodo: (id: string) => {
- set(state => ({
- todos: state.todos.map(todo =>
- todo.id === id
- ? { ...todo, completed: !todo.completed, updatedAt: new Date() }
- : todo
- )
- }));
- },
-
- deleteTodo: (id: string) => {
- set(state => ({
- todos: state.todos.filter(todo => todo.id !== id)
- }));
- },
-
- updateTodo: (id: string, text: string) => {
- set(state => ({
- todos: state.todos.map(todo =>
- todo.id === id
- ? { ...todo, text: text.trim(), updatedAt: new Date() }
- : todo
- )
- }));
- },
-
- setFilter: (filter: TodoFilter) => {
- set({ filter });
- },
-
- clearCompleted: () => {
- set(state => ({
- todos: state.todos.filter(todo => !todo.completed)
- }));
- }
-}));
-
-export const getFilteredTodos = (todos: Todo[], filter: TodoFilter): Todo[] => {
- switch (filter) {
- case 'active':
- return todos.filter(todo => !todo.completed);
- case 'completed':
- return todos.filter(todo => todo.completed);
- default:
- return todos;
- }
-};
-
diff --git a/apps/todo-app/app/root.tsx b/apps/todo-app/app/root.tsx
index b890dfa..d981802 100644
--- a/apps/todo-app/app/root.tsx
+++ b/apps/todo-app/app/root.tsx
@@ -1,6 +1,7 @@
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
import type { MetaFunction, ErrorResponse } from 'react-router';
+import { TodoProvider } from '~/lib/todo-context';
import './globals.css';
export const meta: MetaFunction = () => {
@@ -20,7 +21,9 @@ export default function App() {
-
+
+
+
diff --git a/apps/todo-app/app/routes/home.tsx b/apps/todo-app/app/routes/home.tsx
index ce21e81..b1bbcd0 100644
--- a/apps/todo-app/app/routes/home.tsx
+++ b/apps/todo-app/app/routes/home.tsx
@@ -5,7 +5,7 @@ import { Button } from '@lambdacurry/forms/ui';
import { AddTodo } from '~/components/add-todo';
import { TodoItem } from '~/components/todo-item';
import { TodoFilters } from '~/components/todo-filters';
-import { useTodoStore, getFilteredTodos } from '~/lib/todo-store';
+import { useTodoStore, getFilteredTodos } from '~/lib/todo-context';
import { Settings } from 'lucide-react';
export const meta: MetaFunction = () => {
diff --git a/apps/todo-app/package.json b/apps/todo-app/package.json
index 3d330c1..e6a101e 100644
--- a/apps/todo-app/package.json
+++ b/apps/todo-app/package.json
@@ -23,6 +23,7 @@
"test:ci": "vitest run"
},
"devDependencies": {
+ "@testing-library/react": "^16.1.0",
"@types/node": "^20",
"@vitest/ui": "^3.2.4",
"jsdom": "^26.1.0",
@@ -47,7 +48,6 @@
"react-router": "^7.7.1",
"remix-hook-form": "7.1.0",
"tailwindcss": "^4.1.10",
- "zod": "^3.24.1",
- "zustand": "^5.0.1"
+ "zod": "^3.24.1"
}
}