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" } }