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/)
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
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
function todoReducer(state: Store['todos'] = {}, action: any) {
// reduce on the todos part of the state tree
}
// from last step, using createReducer
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
}
});
// Then use the redux-provided combineReducers() to combine them
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
> 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`
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
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!
5. rewrite all the reducers related to the todos by following instructions
# Further reading

View File

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

View File

@@ -5,25 +5,19 @@ describe('TodoApp reducers', () => {
it('can add an item', () => {
const state = <Store['todos']>{};
const newState = addTodo(state, '0', 'item1');
addTodo(state, { id: '0', label: 'item1' });
const keys = Object.keys(newState);
expect(newState).not.toBe(state);
const keys = Object.keys(state);
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', () => {
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();
addTodo(state, { id: '0', label: 'item1' });
complete(state, { id: '0' });
expect(state['0'].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'] {
return { ...state, [id]: { label, completed: false } };
export function addTodo(state: Store['todos'], action: any) {
state[action.id] = { label: action.label, completed: false };
}
export function remove(state: Store['todos'], id: string) {
const newTodos = { ...state };
delete newTodos[id];
return newTodos;
export function remove(state: Store['todos'], action: any) {
delete state[action.id];
}
export function complete(state: Store['todos'], id: string) {
const newTodos = { ...state };
newTodos[id].completed = !newTodos[id].completed;
return newTodos;
export function complete(state: Store['todos'], action: any) {
state[action.id].completed = !state[action.id].completed;
}
export function clear(state: Store['todos']) {
const newTodos = { ...state };
Object.keys(state).forEach(key => {
if (state[key].completed) {
delete newTodos[key];
delete state[key];
}
});
return newTodos;
}
export function setFilter(state: Store['filter'], filter: FilterTypes) {
return filter;
export function setFilter(state: Store['filter'], action: any) {
return action.filter;
}

View File

@@ -2,6 +2,7 @@ import { Store } from '../store';
import { addTodo, remove, complete, clear, setFilter } from './pureFunctions';
import { combineReducers } from 'redux';
// TODO: rewrite this with createReducer() function
function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] {
switch (action.type) {
case 'addTodo':
@@ -20,9 +21,8 @@ function todoReducer(state: Store['todos'] = {}, action: any): Store['todos'] {
return state;
}
// TODO: rewrite this with createReducer() function
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;
}

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 produce from 'immer';
export function addTodo(state: Store['todos'], id: string, label: string): Store['todos'] {
// TODO: for all the "todos" functions here, rewrite with mutable state
// 1. !!!IMPORTANT!!! change the signature of every function here to:
// 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 } };
}
/* 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) {
// hint: delete state[action.id]
const newTodos = { ...state };
delete newTodos[id];
@@ -25,6 +20,7 @@ export function remove(state: Store['todos'], id: string) {
}
export function complete(state: Store['todos'], id: string) {
// hint: state[action.id].completed = ...
const newTodos = { ...state };
newTodos[id].completed = !newTodos[id].completed;
@@ -32,6 +28,7 @@ export function complete(state: Store['todos'], id: string) {
}
export function clear(state: Store['todos']) {
// hint: it's almost like the remove case above
const newTodos = { ...state };
Object.keys(state).forEach(key => {
@@ -43,6 +40,10 @@ export function clear(state: Store['todos']) {
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) {
return filter;
}

View File

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

View File

@@ -5,25 +5,19 @@ describe('TodoApp reducers', () => {
it('can add an item', () => {
const state = <Store['todos']>{};
const newState = addTodo(state, '0', 'item1');
addTodo(state, { id: '0', label: 'item1' });
const keys = Object.keys(newState);
expect(newState).not.toBe(state);
const keys = Object.keys(state);
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', () => {
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();
addTodo(state, { id: '0', label: 'item1' });
complete(state, { id: '0' });
expect(state['0'].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'] {
return { ...state, [id]: { label, completed: false } };
export function addTodo(state: Store['todos'], action: any) {
state[action.id] = { label: action.label, completed: false };
}
export function edit(state: Store['todos'], id: string, label: string): Store['todos'] {
return { ...state, [id]: { ...state[id], label } };
export function remove(state: Store['todos'], action: any) {
delete state[action.id];
}
export function remove(state: Store['todos'], id: string) {
const newTodos = { ...state };
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 complete(state: Store['todos'], action: any) {
state[action.id].completed = !state[action.id].completed;
}
export function clear(state: Store['todos']) {
const newTodos = { ...state };
Object.keys(state).forEach(key => {
if (state[key].completed) {
delete newTodos[key];
delete state[key];
}
});
return newTodos;
}
export function setFilter(state: Store['filter'], filter: FilterTypes) {
return filter;
export function setFilter(state: Store['filter'], action: any) {
return action.filter;
}

View File

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

View File

@@ -5,25 +5,19 @@ describe('TodoApp reducers', () => {
it('can add an item', () => {
const state = <Store['todos']>{};
const newState = addTodo(state, '0', 'item1');
addTodo(state, { id: '0', label: 'item1' });
const keys = Object.keys(newState);
expect(newState).not.toBe(state);
const keys = Object.keys(state);
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', () => {
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();
addTodo(state, { id: '0', label: 'item1' });
complete(state, { id: '0' });
expect(state['0'].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'] {
return { ...state, [id]: { label, completed: false } };
export function addTodo(state: Store['todos'], action: any) {
state[action.id] = { label: action.label, completed: false };
}
export function edit(state: Store['todos'], id: string, label: string): Store['todos'] {
return { ...state, [id]: { ...state[id], label } };
export function remove(state: Store['todos'], action: any) {
delete state[action.id];
}
export function remove(state: Store['todos'], id: string) {
const newTodos = { ...state };
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 complete(state: Store['todos'], action: any) {
state[action.id].completed = !state[action.id].completed;
}
export function clear(state: Store['todos']) {
const newTodos = { ...state };
Object.keys(state).forEach(key => {
if (state[key].completed) {
delete newTodos[key];
delete state[key];
}
});
return newTodos;
}
export function setFilter(state: Store['filter'], filter: FilterTypes) {
return filter;
export function setFilter(state: Store['filter'], action: any) {
return action.filter;
}