majorly overhauled 2.8

This commit is contained in:
Ken
2019-02-26 21:27:23 -08:00
parent a84b72ae08
commit fdd6d11791
13 changed files with 214 additions and 251 deletions

View File

@@ -2,11 +2,99 @@
[Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/) [Lessons](../) | [Exercise](./exercise/) | [Demo](./demo/)
Combine Reducers The Boilerplate!!
This lesson is just a helper to make the process of writing reducers use less boilerplate code. This step briefly introduces to a world of helpers in writing Redux code. At this point, you might asking why am I adding so much boilerplate code. A lot of code seems to be repeated with Redux. Redux is very much function based and has a lot of opportunites for some refactoring to make it less boilerplate'ish.
Our Redux store so far has this shape, roughly: I argue that part of the boilerplate is just turning what would otherwise by implicit to be explicit. This is GOOD in a large applications so that there is no magic. I argue for two things:
1. writing against immutable data structures is hard
2. the switch statements is cumbersome and error prone (e.g. with default case missing)
# `redux-starter-kit`: A simple batteries-included toolset to make using Redux easier
Introducing an official library from Redux team that makes this much better. We'll start with `createReducer()`
## `createReducer()`: takes away the switch statement
`createReducers()` simplifies things a lot! The best way illustrate what it does is with some code:
```ts
function todoReducer(state, action) {
switch (action.type) {
case 'addTodo':
return addTodo(...)
case 'remove':
return remove(...)
case 'clear':
return clear(...)
case 'complete':
return complete(...)
}
return state;
}
```
can be rewritten as:
```ts
import {createReducer} from 'redux-starter-kit';
const todoReducer = createReducer({}, {
addTodo: (state, action) => ...,
remove: (state, action) => ...,
clear: (state, action) => ...,
complete: (state, action) => ...
})
```
Several important features of `createReducer()`:
1. it allows a more concise way of writing reducers with keys that match the `action.type` (using convention)
2. handles "no match" case and returns the previous state (rather than a blank state like we had done previously)
3. it incorporates a library called [`immer`](https://github.com/mweststrate/immer#reducer-example) that allows us to write code that mutates a draft object and ultimately copies over the old snapshot with the new. Instead of writing immutable data manipulation:
```ts
// Taken from: https://redux.js.org/recipes/structuring-reducers/immutable-update-patterns#inserting-and-removing-items-in-arrays
function insertItem(array, action) {
return [...array.slice(0, action.index), action.item, ...array.slice(action.index)];
}
function removeItem(array, action) {
return [...array.slice(0, action.index), ...array.slice(action.index + 1)];
}
```
Can become code that we write with mutable arrays (without spread syntax):
```ts
function insertItem(array, action) {
// splice is a standard JS Array function
array.splice(action.index, 0, action.item);
}
function removeItem(array, action) {
array.splice(action.index, 1);
}
```
There are cases where you need to replace the entire state at a time (like the `setFilter`). Simply returning a new value without modifying the state like so:
```ts
function setFilter(state, action) {
return action.filter;
}
```
## `combineReducer()` - combining reducers
This another is demonstration of how to write reducers with less boilerplate code. We can use a built-in `redux` function to combineReducers. Application state shape grows usually be splitting the store. Our Redux store so far has this shape, roughly:
```js ```js
const state = { const state = {
@@ -25,16 +113,20 @@ const state = {
}; };
``` ```
As the application grows in complexity, so will the shape of the store. Currently, the store captures two separate but related ideas: the todo items and the selected filter. The reducers should follow the shape of the store. Think of reducers as part of the store itself and are responsible to update a single part of the store based on actions that they receive as a second argument. As complexity of state grows, we split these reducers: Currently, the store captures two separate but related ideas: the todo items and the selected filter. The reducers should follow the shape of the store. Think of reducers as part of the store itself and are responsible to update a single part of the store based on actions that they receive as a second argument. As complexity of state grows, we split these reducers:
```ts ```ts
function todoReducer(state: Store['todos'] = {}, action: any) { // from last step, using createReducer
// reduce on the todos part of the state tree const todoReducer = createReducer(
} {},
{
// reduce on the todos part of the state tree
}
);
function filterReducer(state: Store['filter'] = 'all', action: any) { const filterReducer = createReducer('all', {
// reduce on the filter flag // reduce on the filter flag
} });
// Then use the redux-provided combineReducers() to combine them // Then use the redux-provided combineReducers() to combine them
export const reducer = combineReducers({ export const reducer = combineReducers({
@@ -43,27 +135,21 @@ export const reducer = combineReducers({
}); });
``` ```
`combineReducers` handles the grunt-work of sending *actions* to each combined reducer. Therefore, when an action arrives, each reducer is given the opportunity to modify its own state tree based on the incoming action. `combineReducers` handles the grunt-work of sending _actions_ to each combined reducer. Therefore, when an action arrives, each reducer is given the opportunity to modify its own state tree based on the incoming action.
# Exercise # Exercise
> Hint! This section is tricky, so all the solution is inside "demo" as usual. Feel free to copy & paste if you get stuck!!
1. open up `exercise/src/reducers/index.ts` 1. open up `exercise/src/reducers/index.ts`
2. implement the `filterReducer` function with a switch / case statement - it is contrived to have a switch case for ONE condition, but serves to be a good exercise here 2. rewrite the reducer functions `todoReducers`, `filterReducers` with the help of `createReducer()`
3. replace the export reducer function with the help of the `combineReducer()` function from `redux` 3. rewrite the `reducer()` function with `combineReducer()`
# Bonus Exercise 4. open up `exercise/src/reducers/pureFunctions.ts`
The Redux team came up with `redux-starter-kit` to address a lot of boilerplate concerns. They also embed the immer library to make it nicer to write reducer functions. So, let's try out `immer`! Look at this example: https://github.com/mweststrate/immer#reducer-example 5. rewrite all the reducers related to the todos by following instructions
1. import immer into the `exercise/src/reducers/pureFunction.ts` file
2. replace the implementation of the pure functions with the help of immer's `produce()`
3. run `npm test` in the root folder to see if it still works!
4. look at the web app to make sure it still works!
# Further reading # Further reading

View File

@@ -1,33 +1,21 @@
import { Store } from '../store'; import { Store } from '../store';
import { addTodo, remove, complete, clear, setFilter } from './pureFunctions'; import { addTodo, remove, complete, clear, setFilter } from './pureFunctions';
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { createReducer } from 'redux-starter-kit';
function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] { const todoReducer = createReducer<Store['todos']>(
switch (action.type) { {},
case 'addTodo': {
return addTodo(state, action.id, action.label); addTodo,
remove,
case 'remove': clear,
return remove(state, action.id); complete
case 'clear':
return clear(state);
case 'complete':
return complete(state, action.id);
} }
);
return state; const filterReducer = createReducer<Store['filter']>('all', {
} setFilter
});
function filterReducer(state: Store['filter'] = 'all', action: any): Store['filter'] {
switch (action.type) {
case 'setFilter':
return setFilter(state, action.filter);
}
return state;
}
export const reducer = combineReducers({ export const reducer = combineReducers({
todos: todoReducer, todos: todoReducer,

View File

@@ -5,25 +5,19 @@ describe('TodoApp reducers', () => {
it('can add an item', () => { it('can add an item', () => {
const state = <Store['todos']>{}; const state = <Store['todos']>{};
const newState = addTodo(state, '0', 'item1'); addTodo(state, { id: '0', label: 'item1' });
const keys = Object.keys(newState); const keys = Object.keys(state);
expect(newState).not.toBe(state);
expect(keys.length).toBe(1); expect(keys.length).toBe(1);
expect(newState[keys[0]].label).toBe('item1');
expect(newState[keys[0]].completed).toBeFalsy(); expect(state['0'].label).toBe('item1');
expect(state['0'].completed).toBeFalsy();
}); });
it('can complete an item', () => { it('can complete an item', () => {
const state = <Store['todos']>{}; const state = <Store['todos']>{};
addTodo(state, { id: '0', label: 'item1' });
let newState = addTodo(state, '0', 'item1'); complete(state, { id: '0' });
expect(state['0'].completed).toBeTruthy();
const key = Object.keys(newState)[0];
newState = complete(newState, key);
expect(newState[key].completed).toBeTruthy();
}); });
}); });

View File

@@ -1,36 +1,25 @@
import { Store, FilterTypes } from '../store'; import { Store } from '../store';
export function addTodo(state: Store['todos'], id: string, label: string): Store['todos'] { export function addTodo(state: Store['todos'], action: any) {
return { ...state, [id]: { label, completed: false } }; state[action.id] = { label: action.label, completed: false };
} }
export function remove(state: Store['todos'], id: string) { export function remove(state: Store['todos'], action: any) {
const newTodos = { ...state }; delete state[action.id];
delete newTodos[id];
return newTodos;
} }
export function complete(state: Store['todos'], id: string) { export function complete(state: Store['todos'], action: any) {
const newTodos = { ...state }; state[action.id].completed = !state[action.id].completed;
newTodos[id].completed = !newTodos[id].completed;
return newTodos;
} }
export function clear(state: Store['todos']) { export function clear(state: Store['todos']) {
const newTodos = { ...state };
Object.keys(state).forEach(key => { Object.keys(state).forEach(key => {
if (state[key].completed) { if (state[key].completed) {
delete newTodos[key]; delete state[key];
} }
}); });
return newTodos;
} }
export function setFilter(state: Store['filter'], filter: FilterTypes) { export function setFilter(state: Store['filter'], action: any) {
return filter; return action.filter;
} }

View File

@@ -2,6 +2,7 @@ import { Store } from '../store';
import { addTodo, remove, complete, clear, setFilter } from './pureFunctions'; import { addTodo, remove, complete, clear, setFilter } from './pureFunctions';
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
// TODO: rewrite this with createReducer() function
function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] { function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] {
switch (action.type) { switch (action.type) {
case 'addTodo': case 'addTodo':
@@ -20,9 +21,8 @@ function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] {
return state; return state;
} }
// TODO: rewrite this with createReducer() function
function filterReducer(state: Store['filter'] = 'all', action: any): Store['filter'] { function filterReducer(state: Store['filter'] = 'all', action: any): Store['filter'] {
// TODO: fill in the blank here with a switch / case statement to return new filter state as specified in `action.filter` message
return state; return state;
} }

View File

@@ -1,29 +0,0 @@
import { addTodo, complete } from './pureFunctions';
import { Store } from '../store';
describe('TodoApp reducers', () => {
it('can add an item', () => {
const state = <Store['todos']>{};
const newState = addTodo(state, '0', 'item1');
const keys = Object.keys(newState);
expect(newState).not.toBe(state);
expect(keys.length).toBe(1);
expect(newState[keys[0]].label).toBe('item1');
expect(newState[keys[0]].completed).toBeFalsy();
});
it('can complete an item', () => {
const state = <Store['todos']>{};
let newState = addTodo(state, '0', 'item1');
const key = Object.keys(newState)[0];
newState = complete(newState, key);
expect(newState[key].completed).toBeTruthy();
});
});

View File

@@ -1,22 +1,17 @@
import { Store, FilterTypes } from '../store'; import { Store, FilterTypes } from '../store';
import produce from 'immer'; // TODO: for all the "todos" functions here, rewrite with mutable state
// 1. !!!IMPORTANT!!! change the signature of every function here to:
export function addTodo(state: Store['todos'], id: string, label: string): Store['todos'] { // function xyzAction(state: Store['todos'], action: any) { ... }
//
// 2. make sure NOT to return anything, just modify the "state" arg
export function addTodo(state: Store['todos'], id: string, label: string) {
// hint: state[action.id] = ...
return { ...state, [id]: { label, completed: false } }; return { ...state, [id]: { label, completed: false } };
} }
/* For the bonus exercise
export function addTodo(state: Store['todos'], id: string, label: string): Store['todos'] {
return produce(state, draft => {
// TODO: implement a simple obj key assignment here
});
}
*/
export function remove(state: Store['todos'], id: string) { export function remove(state: Store['todos'], id: string) {
// hint: delete state[action.id]
const newTodos = { ...state }; const newTodos = { ...state };
delete newTodos[id]; delete newTodos[id];
@@ -25,6 +20,7 @@ export function remove(state: Store['todos'], id: string) {
} }
export function complete(state: Store['todos'], id: string) { export function complete(state: Store['todos'], id: string) {
// hint: state[action.id].completed = ...
const newTodos = { ...state }; const newTodos = { ...state };
newTodos[id].completed = !newTodos[id].completed; newTodos[id].completed = !newTodos[id].completed;
@@ -32,6 +28,7 @@ export function complete(state: Store['todos'], id: string) {
} }
export function clear(state: Store['todos']) { export function clear(state: Store['todos']) {
// hint: it's almost like the remove case above
const newTodos = { ...state }; const newTodos = { ...state };
Object.keys(state).forEach(key => { Object.keys(state).forEach(key => {
@@ -43,6 +40,10 @@ export function clear(state: Store['todos']) {
return newTodos; return newTodos;
} }
// TODO: change the setFilter() to the new immer way
// 1. change the signature of every function here to:
// function xyzAction(state: Store['todos'], action: any) { ... }
// 2. make sure to return action.filter without modifying state in this case
export function setFilter(state: Store['filter'], filter: FilterTypes) { export function setFilter(state: Store['filter'], filter: FilterTypes) {
return filter; return filter;
} }

View File

@@ -1,33 +1,21 @@
import { Store } from '../store'; import { Store } from '../store';
import { addTodo, remove, complete, clear, setFilter } from './pureFunctions'; import { addTodo, remove, complete, clear, setFilter } from './pureFunctions';
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { createReducer } from 'redux-starter-kit';
function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] { const todoReducer = createReducer<Store['todos']>(
switch (action.type) { {},
case 'addTodo': {
return addTodo(state, action.id, action.label); addTodo,
remove,
case 'remove': clear,
return remove(state, action.id); complete
case 'clear':
return clear(state);
case 'complete':
return complete(state, action.id);
} }
);
return state; const filterReducer = createReducer<Store['filter']>('all', {
} setFilter
});
function filterReducer(state: Store['filter'] = 'all', action: any): Store['filter'] {
switch (action.type) {
case 'setFilter':
return setFilter(state, action.filter);
}
return state;
}
export const reducer = combineReducers({ export const reducer = combineReducers({
todos: todoReducer, todos: todoReducer,

View File

@@ -5,25 +5,19 @@ describe('TodoApp reducers', () => {
it('can add an item', () => { it('can add an item', () => {
const state = <Store['todos']>{}; const state = <Store['todos']>{};
const newState = addTodo(state, '0', 'item1'); addTodo(state, { id: '0', label: 'item1' });
const keys = Object.keys(newState); const keys = Object.keys(state);
expect(newState).not.toBe(state);
expect(keys.length).toBe(1); expect(keys.length).toBe(1);
expect(newState[keys[0]].label).toBe('item1');
expect(newState[keys[0]].completed).toBeFalsy(); expect(state['0'].label).toBe('item1');
expect(state['0'].completed).toBeFalsy();
}); });
it('can complete an item', () => { it('can complete an item', () => {
const state = <Store['todos']>{}; const state = <Store['todos']>{};
addTodo(state, { id: '0', label: 'item1' });
let newState = addTodo(state, '0', 'item1'); complete(state, { id: '0' });
expect(state['0'].completed).toBeTruthy();
const key = Object.keys(newState)[0];
newState = complete(newState, key);
expect(newState[key].completed).toBeTruthy();
}); });
}); });

View File

@@ -1,40 +1,25 @@
import { Store, FilterTypes } from '../store'; import { Store } from '../store';
export function addTodo(state: Store['todos'], id: string, label: string): Store['todos'] { export function addTodo(state: Store['todos'], action: any) {
return { ...state, [id]: { label, completed: false } }; state[action.id] = { label: action.label, completed: false };
} }
export function edit(state: Store['todos'], id: string, label: string): Store['todos'] { export function remove(state: Store['todos'], action: any) {
return { ...state, [id]: { ...state[id], label } }; delete state[action.id];
} }
export function remove(state: Store['todos'], id: string) { export function complete(state: Store['todos'], action: any) {
const newTodos = { ...state }; state[action.id].completed = !state[action.id].completed;
delete newTodos[id];
return newTodos;
}
export function complete(state: Store['todos'], id: string) {
const newTodos = { ...state };
newTodos[id].completed = !newTodos[id].completed;
return newTodos;
} }
export function clear(state: Store['todos']) { export function clear(state: Store['todos']) {
const newTodos = { ...state };
Object.keys(state).forEach(key => { Object.keys(state).forEach(key => {
if (state[key].completed) { if (state[key].completed) {
delete newTodos[key]; delete state[key];
} }
}); });
return newTodos;
} }
export function setFilter(state: Store['filter'], filter: FilterTypes) { export function setFilter(state: Store['filter'], action: any) {
return filter; return action.filter;
} }

View File

@@ -1,33 +1,21 @@
import { Store } from '../store'; import { Store } from '../store';
import { addTodo, remove, complete, clear, setFilter } from './pureFunctions'; import { addTodo, remove, complete, clear, setFilter } from './pureFunctions';
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { createReducer } from 'redux-starter-kit';
function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] { const todoReducer = createReducer<Store['todos']>(
switch (action.type) { {},
case 'addTodo': {
return addTodo(state, action.id, action.label); addTodo,
remove,
case 'remove': clear,
return remove(state, action.id); complete
case 'clear':
return clear(state);
case 'complete':
return complete(state, action.id);
} }
);
return state; const filterReducer = createReducer<Store['filter']>('all', {
} setFilter
});
function filterReducer(state: Store['filter'] = 'all', action: any): Store['filter'] {
switch (action.type) {
case 'setFilter':
return setFilter(state, action.filter);
}
return state;
}
export const reducer = combineReducers({ export const reducer = combineReducers({
todos: todoReducer, todos: todoReducer,

View File

@@ -5,25 +5,19 @@ describe('TodoApp reducers', () => {
it('can add an item', () => { it('can add an item', () => {
const state = <Store['todos']>{}; const state = <Store['todos']>{};
const newState = addTodo(state, '0', 'item1'); addTodo(state, { id: '0', label: 'item1' });
const keys = Object.keys(newState); const keys = Object.keys(state);
expect(newState).not.toBe(state);
expect(keys.length).toBe(1); expect(keys.length).toBe(1);
expect(newState[keys[0]].label).toBe('item1');
expect(newState[keys[0]].completed).toBeFalsy(); expect(state['0'].label).toBe('item1');
expect(state['0'].completed).toBeFalsy();
}); });
it('can complete an item', () => { it('can complete an item', () => {
const state = <Store['todos']>{}; const state = <Store['todos']>{};
addTodo(state, { id: '0', label: 'item1' });
let newState = addTodo(state, '0', 'item1'); complete(state, { id: '0' });
expect(state['0'].completed).toBeTruthy();
const key = Object.keys(newState)[0];
newState = complete(newState, key);
expect(newState[key].completed).toBeTruthy();
}); });
}); });

View File

@@ -1,40 +1,25 @@
import { Store, FilterTypes } from '../store'; import { Store } from '../store';
export function addTodo(state: Store['todos'], id: string, label: string): Store['todos'] { export function addTodo(state: Store['todos'], action: any) {
return { ...state, [id]: { label, completed: false } }; state[action.id] = { label: action.label, completed: false };
} }
export function edit(state: Store['todos'], id: string, label: string): Store['todos'] { export function remove(state: Store['todos'], action: any) {
return { ...state, [id]: { ...state[id], label } }; delete state[action.id];
} }
export function remove(state: Store['todos'], id: string) { export function complete(state: Store['todos'], action: any) {
const newTodos = { ...state }; state[action.id].completed = !state[action.id].completed;
delete newTodos[id];
return newTodos;
}
export function complete(state: Store['todos'], id: string) {
const newTodos = { ...state };
newTodos[id].completed = !newTodos[id].completed;
return newTodos;
} }
export function clear(state: Store['todos']) { export function clear(state: Store['todos']) {
const newTodos = { ...state };
Object.keys(state).forEach(key => { Object.keys(state).forEach(key => {
if (state[key].completed) { if (state[key].completed) {
delete newTodos[key]; delete state[key];
} }
}); });
return newTodos;
} }
export function setFilter(state: Store['filter'], filter: FilterTypes) { export function setFilter(state: Store['filter'], action: any) {
return filter; return action.filter;
} }