├── .prettierrc ├── README.md ├── package.json ├── public └── index.html ├── sandbox.config.json ├── src ├── components │ ├── TodoForm.js │ ├── TodoHeader.js │ └── TodoList.js ├── context.js ├── index.js ├── reducer.js └── usePersist.js └── test ├── TodoForm.test.js ├── TodoList.test.js └── TodoStore.test.js /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false, 10 | "fluid": false 11 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Hooks Todo App 2 | 3 | > A trial to achieve a correct approach. Trying to get **rid off using Redux**, make **contexts more useful** with **useReducer** and make components **"easy-to-test simple functions"**. 4 | 5 | [![Edit react-usecontext-todo-app](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/github/f/react-hooks-todo-app/tree/master/) 6 | 7 | --- 8 | 9 | A **highly decoupled**, **testable** TodoList app that uses React hooks. 10 | 11 | This is a training repo to learn about new hooks feature of React and creating a testable environment. 12 | 13 | - **Zero-dependency** 14 | - **No** class components 15 | - Uses `Context` to share a **global state** 16 | - Uses `useReducer` to manage **state actions** 17 | - `useState` to create local state 18 | - Decoupled state logic (Actions) 19 | - Testable components (Uses Jest + Enzyme for tests) 20 | - Custom Hooks for **persisting state**. 21 | 22 | For better approaches please open Pull Requests 23 | 24 | ## Summary 25 | 26 | ### 1. **Context**: 27 | 28 | The main approach was to get rid off Redux and use **React Contexts** instead. With the composition of `useState`, `useContext` I created a global state. And passed it into a **custom hook** called `useTodos`. `useTodos` curries `useState` output and generates a state manager which will be passed into `TodoContext.Provider` to be used as a global state. 29 | 30 | ```jsx 31 | function App() { 32 | // create a global store to store the state 33 | const globalStore = useContext(Store); 34 | 35 | // `todos` will be a state manager to manage state. 36 | const [state, dispatch] = useReducer(reducer, globalStore); 37 | 38 | return ( 39 | // State.Provider passes the state and dispatcher to the down 40 | 41 | 42 | 43 | 44 | ); 45 | } 46 | ``` 47 | 48 | ### 2. **The Reducer**: 49 | 50 | The second approach was to seperate the main logic, just as the **actions** of Redux. But these are fully functional, every function returns whole state. 51 | 52 | ```js 53 | // Reducer is the classical reducer that we know from Redux. 54 | // used by `useReducer` 55 | export default function reducer(state, action) { 56 | switch (action.type) { 57 | case "ADD_TODO": 58 | return { 59 | ...state, 60 | todos: [...state.todos, action.payload] 61 | }; 62 | case "COMPLETE": 63 | return { 64 | ...state, 65 | todos: state.todos.filter(t => t !== action.payload) 66 | }; 67 | default: 68 | return state; 69 | } 70 | } 71 | ``` 72 | 73 | ### 3. **State and Dispatcher** 74 | 75 | I reach out **state and dispathcer** of context using `useContext` and I can reach to the `actions`. 76 | 77 | ```js 78 | import React, { useContext } from "react"; 79 | import Store from "../context"; 80 | 81 | export default function TodoForm() { 82 | const { state, dispatch } = useContext(Store); 83 | // use `state.todos` to get todos 84 | // use `dispatch({ type: 'ADD_TODO', payload: 'Buy milk' })` 85 | ``` 86 | 87 | ### 4. **Persistence with custom hooks**: 88 | 89 | I created custom hooks to persist state on `localStorage` 90 | 91 | ```js 92 | import { useEffect } from "react"; 93 | 94 | // Accepts `useContext` as first parameter and returns cached context. 95 | export function usePersistedContext(context, key = "state") { 96 | const persistedContext = localStorage.getItem(key); 97 | return persistedContext ? JSON.parse(persistedContext) : context; 98 | } 99 | 100 | // Accepts `useReducer` as first parameter and returns cached reducer. 101 | export function usePersistedReducer([state, dispatch], key = "state") { 102 | useEffect(() => localStorage.setItem(key, JSON.stringify(state)), [state]); 103 | return [state, dispatch]; 104 | } 105 | ``` 106 | 107 | The `App` function will be: 108 | 109 | ```js 110 | function App () { 111 | const globalStore = usePersistedContext(useContext(Store)); 112 | 113 | // `todos` will be a state manager to manage state. 114 | const [state, dispatch] = usePersistedReducer(useReducer(reducer, globalStore)); 115 | ``` 116 | 117 | ### 5. **Everything is testable decoupled**: 118 | 119 | The last but most important part of the approach is to make all the parts testable. They don't tie to eachother which makes me to write tests easily. 120 | 121 | ## License 122 | MIT 123 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-todo-app-hooks-and-context", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "A highly testable TodoList app that uses React hooks and Context.", 6 | "keywords": [], 7 | "main": "src/index.js", 8 | "dependencies": { 9 | "enzyme": "3.7.0", 10 | "enzyme-adapter-react-16": "1.6.0", 11 | "react": "next", 12 | "react-dom": "next", 13 | "react-scripts": "2.0.3" 14 | }, 15 | "devDependencies": {}, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test --env=jsdom", 20 | "eject": "react-scripts eject" 21 | }, 22 | "browserslist": [ 23 | ">0.2%", 24 | "not dead", 25 | "not ie <= 11", 26 | "not op_mini all" 27 | ] 28 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 24 | React App 25 | 26 | 27 | 28 | 31 |
32 |
33 |
34 | 35 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "infiniteLoopProtection": true, 3 | "hardReloadOnChange": false, 4 | "view": "browser" 5 | } -------------------------------------------------------------------------------- /src/components/TodoForm.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react"; 2 | import Store from "../context"; 3 | 4 | export default function TodoForm() { 5 | const { dispatch } = useContext(Store); 6 | 7 | // Creating a local state to have currently writing 8 | // todo item that will be sent to the global store. 9 | const [todo, setTodo] = useState(""); 10 | 11 | function handleTodoChange(e) { 12 | setTodo(e.target.value); 13 | } 14 | 15 | function handleTodoAdd() { 16 | dispatch({ type: "ADD_TODO", payload: todo }); 17 | setTodo(""); 18 | } 19 | 20 | function handleSubmitForm(event) { 21 | if (event.keyCode === 13) handleTodoAdd(); 22 | } 23 | 24 | return ( 25 |
26 |
27 |
28 |
29 | 37 |
38 | 41 |
42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/TodoHeader.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const TodoHeader = (props) => ( 4 |
5 |
6 |
Todo List
7 |
8 |
9 | {props.children} 10 |
11 |
12 | ); 13 | -------------------------------------------------------------------------------- /src/components/TodoList.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import Store from "../context"; 3 | import { TodoHeader } from "./TodoHeader"; 4 | 5 | export default function TodoList() { 6 | const { state, dispatch } = useContext(Store); 7 | 8 | const pluralize = count => 9 | count > 1 ? `There are ${count} todos.` : `There is ${count} todo.`; 10 | 11 | let header = 12 | state.todos.length === 0 ? ( 13 |

Yay! All todos are done! Take a rest!

14 | ) : ( 15 | 16 | {pluralize(state.todos.length)} 17 | 18 | ); 19 | return ( 20 |
21 |
22 |
23 |
24 |
25 | {header} 26 |
27 |
28 |
29 |
30 |
    31 | {state.todos.map(t => ( 32 |
  • 33 | {t} 34 | 41 |
  • 42 | ))} 43 |
44 |
45 |
46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | // Store Context is the global context that is managed by reducers. 4 | 5 | const Store = React.createContext({ 6 | todos: [ 7 | // Initial Data 8 | "Buy milk", 9 | "Some eggs", 10 | "Go to work" 11 | ] 12 | }); 13 | 14 | export default Store; 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useReducer } from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import Store from "./context"; 5 | import reducer from "./reducer"; 6 | 7 | import { usePersistedContext, usePersistedReducer } from "./usePersist"; 8 | 9 | import TodoList from "./components/TodoList"; 10 | import TodoForm from "./components/TodoForm"; 11 | 12 | function App() { 13 | // create a global store to store the state 14 | const globalStore = usePersistedContext(useContext(Store), "state"); 15 | 16 | // `todos` will be a state manager to manage state. 17 | const [state, dispatch] = usePersistedReducer( 18 | useReducer(reducer, globalStore), 19 | "state" // The localStorage key 20 | ); 21 | 22 | return ( 23 | // State.Provider passes the state and dispatcher to the down 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | const rootElement = document.getElementById("root"); 32 | ReactDOM.render(, rootElement); 33 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | export default function reducer(state, action) { 2 | switch (action.type) { 3 | case "ADD_TODO": 4 | // return current state if empty 5 | if (!action.payload) { 6 | return state; 7 | } 8 | // return current state if duplicate 9 | if (state.todos.includes(action.payload)) { 10 | return state; 11 | } 12 | return { 13 | ...state, 14 | todos: [...state.todos, action.payload] 15 | }; 16 | case "COMPLETE": 17 | return { 18 | ...state, 19 | todos: state.todos.filter(t => t !== action.payload) 20 | }; 21 | default: 22 | return state; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/usePersist.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export function usePersistedContext(context, key = "state") { 4 | const persistedContext = localStorage.getItem(key); 5 | return persistedContext ? JSON.parse(persistedContext) : context; 6 | } 7 | 8 | export function usePersistedReducer([state, dispatch], key = "state") { 9 | useEffect(() => localStorage.setItem(key, JSON.stringify(state)), [state]); 10 | return [state, dispatch]; 11 | } 12 | -------------------------------------------------------------------------------- /test/TodoForm.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Enzyme, { mount } from "enzyme"; 3 | import Adapter from "enzyme-adapter-react-16"; 4 | 5 | import Store from "../src/context"; 6 | import TodoForm from "../src/components/TodoForm"; 7 | 8 | Enzyme.configure({ adapter: new Adapter() }); 9 | 10 | test(" #addTodo", async () => { 11 | const dispatch = jest.fn(); 12 | const form = mount( 13 | 14 | 15 | 16 | ); 17 | 18 | form.find("input").simulate("change", { target: { value: "a new todo" } }); 19 | form.find("button").simulate("click"); 20 | 21 | expect(dispatch).toBeCalledWith({ type: "ADD_TODO", payload: "a new todo" }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/TodoList.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Enzyme, { mount } from "enzyme"; 3 | import Adapter from "enzyme-adapter-react-16"; 4 | 5 | import Store from "../src/context"; 6 | import reducer from "../src/reducer"; 7 | import TodoList from "../src/components/TodoList"; 8 | 9 | Enzyme.configure({ adapter: new Adapter() }); 10 | 11 | test(" #display", async () => { 12 | const todos = ["a", "b", "c"]; 13 | const dispatch = () => {}; 14 | const list = mount( 15 | 16 | 17 | 18 | ); 19 | 20 | expect(list.find("li").length).toEqual(3); 21 | expect( 22 | list 23 | .find("li") 24 | .first() 25 | .html() 26 | ).toEqual( 27 | '
  • a
  • ' 28 | ); 29 | expect( 30 | list 31 | .find("li") 32 | .last() 33 | .html() 34 | ).toEqual( 35 | '
  • c
  • ' 36 | ); 37 | }); 38 | 39 | test(" #completeCalls", async () => { 40 | const todos = ["a", "b", "c"]; 41 | const dispatch = jest.fn(); 42 | const list = mount( 43 | 44 | 45 | 46 | ); 47 | 48 | list.find("button").forEach(b => b.simulate("click")); 49 | expect(dispatch.mock.calls.length).toBe(3); 50 | }); 51 | 52 | test(" #completeMutates", async () => { 53 | let state = { todos: ["a", "b", "c"] }; 54 | const dispatch = action => { 55 | state = reducer(state, action); 56 | }; 57 | const list = mount( 58 | 59 | 60 | 61 | ); 62 | 63 | await list 64 | .find("button") 65 | .last() 66 | .simulate("click"); 67 | expect(state.todos.length).toBe(2); 68 | expect(state.todos).toEqual(["a", "b"]); 69 | }); 70 | -------------------------------------------------------------------------------- /test/TodoStore.test.js: -------------------------------------------------------------------------------- 1 | import reducer from "../src/reducer"; 2 | 3 | test("adds todo", async () => { 4 | const state = { todos: ["a"] }; 5 | const newState = reducer(state, { type: "ADD_TODO", payload: "b" }); 6 | 7 | expect(newState.todos).toEqual(["a", "b"]); 8 | }); 9 | 10 | test("completes todo", async () => { 11 | const state = { todos: ["a", "b"] }; 12 | const newState = reducer(state, { type: "COMPLETE", payload: "b" }); 13 | 14 | expect(newState.todos).toEqual(["a"]); 15 | }); 16 | --------------------------------------------------------------------------------