├── .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 | [](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 |
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 |
--------------------------------------------------------------------------------