Sobre el Instructor
Web Developer en Joinnus.com
Fundador de la comunidad Laravel Perú
Love ultimate frisbee and watching NFL
Thinking in React: Components
Traditional MVC
Controller
Model
Model
Model
Model
Model
View
View
View
View
View
Flux
Dispatcher
Store
View
Action
Action
"A predictable state container for JavaScript apps."
Single store for all application state
Never modify state directly
"Actions" are dispatched and "reducers" know how state should be changed for each action
Popular implementation of the Flux architecture
// reducer.js
export function reducer(state, action) {
switch (action.type) {
case ADD_TODO:
return Object.assign({}, state, {
todos: [
...state.todos,
{
text: action.text,
completed: false
}
]
});
default:
return state;
}
}
// reducer-spec.js
import { reducer } from './reducer';
const state = {
todos: []
};
const action = {
type: 'ADD_TODO',
text: 'Write unit tests'
};
describe('todos reducer', () => {
it('should handle the "ADD_TODO" action', () => {
const newState = reducer(state, action);
expect(newState.todos[0].text).toEqual(action.text);
expect(newState.todos[0].completed).toBeFalsy();
})
})
(RIP Gene Wilder)
The struggle is real
Smart containers and dumb components
Dumb Component Example
const DumbComponent = (props) => {
const { text, onChange } = props;
return (
<div>
<p>Your text: {text}</p>
<input value={text} onChange={onChange} />
</div>
);
};
Smart Container Example
//...
import { updateText } from '../actions';
//...
class SmartContainer extends React.Component {
render() {
const { text, updateText } = this.props;
return (
<DumbComponent text={text} onChange={updateText} />
);
}
}
const mapStateToProps = (state) => ({
text: state.text
});
const mapDispatchToProps = (dispatch) => ({
updateText: (evt) => dispatch(updateText(evt.target.text))
});
export default connect(mapStateToProps, mapDispatchToProps)(SmartContainer);
Immutability
1 === 1
'string' === 'string'
true === true
const obj1 = { prop: ’someValue’ };
const obj2 = { prop: ’someValue’ };
console.log(obj1 === obj2); // false
class NewsFeed extends React.Component {
shouldComponentUpdate(nextProps) {
return !_.isEqual(this.props.articles, nextProps.articles) ||
!_.isEqual(this.props.user, nextProps.user);
}
render() {
const { user, articles } = this.props;
return (
<div>
<p>Hello, {user.firstName}! Here is your news for the day:</p>
<div>
{
articles.map((article) => (
<Article article=[article} />
))
}
</div>
</div>
);
}
}
shouldComponentUpdate(nextProps) {
return this.props.articles !== nextProps.articles ||
this.props.user !== nextProps.user;
}
shouldComponentUpdate(nextProps, nextState) {
return shallowCompare(this, nextProps, nextState);
}
Normalized state tree
// API response
[
{
"id": 1,
"title": "Why React is Awesome",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
},
{
"id": 2,
"title": "Why Redux is Great",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
}
]
// Redux state
{
articles: [{
id: 1,
title: 'Why React is Awesome',
author: {
id: 1,
name: 'Joel Kanzelmeyer'
}
}, {
id: 2,
title: 'Why Redux is Great',
author: {
id: 1,
name: 'Joel Kanzelmeyer'
}
}]
}
// Redux state
{
articles: [{
id: 1,
title: 'Why React is Awesome',
author: {
id: 1,
name: 'Joel Kanzelmeyer'
}
}, {
id: 2,
title: 'Why Redux is Great',
author: {
id: 1,
name: 'Joel Cancelmeyer'
}
}]
}
// API response
[
{
"id": 1,
"title": "Why React is Awesome",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
},
{
"id": 2,
"title": "Why Redux is Great",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
}
]
// Normalized result
{
entities: {
authors: {
1: {
id: 1,
name: 'Joel Kanzelmeyer'
}
},
articles: {
1: {
id: 1,
name: 'Why React is Awesome'
},
2: {
id: 2,
name: 'Why Redux is Great'
}
}
},
result: {
authors: [1],
articles: [1, 2]
}
}
// Redux state
{
articles: [{
id: 1,
title: 'Why React is Awesome',
author: {
id: 1,
name: 'Joel Kanzelmeyer'
}
}, {
id: 2,
title: 'Why Redux is Great',
author: {
id: 1,
name: 'Joel Kanzelmeyer'
}
}]
}
// Redux state with normalizr
{
authors: {
byId: {
1: {
id: 1,
name: 'Joel Kanzelmeyer'
}
},
all: [1]
},
articles: {
byId: {
1: {
id: 1,
title: 'Why React is Awesome',
author: 1
},
2: {
id: 2,
title: 'Why Redux is Great',
author: 1
}
},
all: [1, 2]
}
}
import { Schema, arrayOf } from 'normalizr';
// define your schemas
const article = new Schema('articles');
const author = new Schema('authors');
// define nesting rules
article.define({
author: author
});
export const getArticlesSuccess = (response) => ({
type: 'GET_ARTICLES_SUCCESS',
payload: response.body,
meta: {
// provide middleware with normalizr schema
schema: arrayOf(article)
}
});
// API response
[
{
"id": 1,
"title": "Why React is Awesome",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
},
{
"id": 2,
"title": "Why Redux is Great",
"author": {
"id": 1,
"name": "Joel Kanzelmeyer"
}
}
]
// define initial state for reducers
const initialState = Immutable.Map({
byId: Immutable.Map(),
all: Immutable.Set()
});
// define default reducer that will merge in normalized state
const defaultReducer = (schemaName, state, action) => {
if (action.entities && action.entities[schemaName]) {
return state
.mergeIn(['byId'], action.entities[schemaName])
.mergeIn(['all'], action.result[schemaName]);
}
return state;
}
// authors reducer
const authors = (state = initialState, action) => {
switch (action.type) {
case 'UPDATE_AUTHOR':
return state.mergeIn(['byId', action.id], action.author);
default:
return defaultReducer('authors', state, action);
}
};
// articles reducer
const articles = (state = initialState, action) => {
return defaultReducer('articles', state, action);
};
// root reducer
const reducer = combineReducers({
authors,
articles
});
Asynchronous/chained actions
(actions with side-effects)
// API service
const getBooks = () =>
fetch('/api/books')
.then(handleErrors)
.then(parseJSON);
// View
vm.loadBooks = () => {
vm.booksLoading = true;
booksApi.getBooks()
.then((books) => {
vm.booksLoading = false;
vm.books = books;
})
.catch((err) => {
vm.booksLoading = false;
vm.error = err;
});
};
MVC
// Actions
const getBooks = () => {
return fetch('/api/books')
.then(handleErrors)
.then(parseJSON)
.then((books) => ({
type: 'GET_BOOKS',
payload: books
});
};
onComponentWillMount() {
this.props.getBooks();
// Uncaught Error: Actions must be plain objects.
// Use custom middleware for async actions.
}
React/Redux
// Actions
const getBooks = () => ({
type: 'GET_BOOKS'
});
const getBooksSuccess = (books) => ({
type: 'GET_BOOKS_SUCCESS',
payload: books
});
const getBooksFailure = (error) => ({
type: 'GET_BOOKS_FAILURE',
payload: error
});
// Reducer
const booksReducer = (state, action) => {
switch (action.type) {
case 'GET_BOOKS':
return {
...state,
loading: true,
error: null
};
case 'GET_BOOKS_SUCCESS':
return {
...state,
loading: false,
books: action.payload
};
case 'GET_BOOKS_FAILURE':
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
};
function incrementIfOdd() {
return (dispatch, getState) => {
const { counter } = getState();
if (counter % 2 === 0) {
return;
}
dispatch(increment());
};
}
Example with redux-thunk
// Actions
// create a thunked action
const getBooks = () => {
return (dispatch, getState) => {
// check to see if books have already been fetched
if (!getState().books) {
// if not, dispatch the GET_BOOKS action
dispatch({
type: 'GET_BOOKS'
});
// then you make your http request
booksApi.getBooks()
.then((books) => {
// dispatch success action
dispatch(getBooksSuccess(books));
})
.catch((error) => {
// dispatch failure action
dispatch(getBooksFailure(error));
});
}
};
};
const getBooksSuccess = (books) => ({
type: 'GET_BOOKS_SUCCESS',
payload: books
});
const getBooksFailure = (error) => ({
type: 'GET_BOOKS_FAILURE',
payload: error
});
// in your component
onComponentWillMount() {
this.props.getBooks();
/**
dispatches GET_BOOKS and
then when response comes back
it will dispatch either
GET_BOOKS_SUCCESS or
GET_BOOKS_FAILURE
**/
}
render() {
const {
isLoading,
error,
books
} = this.props;
if (isLoading) {
return (
<div>loading...</div>
);
}
if (error) {
return (
<div>error: {error}</div>
);
}
return (
<BooksList books={books} />
);
}
class UserComponent extends React.Component {
...
onSomeButtonClicked() {
const { userId, dispatch } = this.props
dispatch({type: 'USER_FETCH_REQUESTED', payload: {userId}})
}
...
}
import { takeEvery } from 'redux-saga'
import { call, put } from 'redux-saga/effects'
import Api from '...'
// worker Saga: will be fired on USER_FETCH_REQUESTED actions
function* fetchUser(action) {
try {
const user = yield call(Api.fetchUser, action.payload.userId);
yield put({type: 'USER_FETCH_SUCCEEDED', user: user});
} catch (e) {
yield put({type: 'USER_FETCH_FAILED', message: e.message});
}
}
/*
Starts fetchUser on each dispatched 'USER_FETCH_REQUESTED' action.
Allows concurrent fetches of user.
*/
function* mySaga() {
yield* takeEvery('USER_FETCH_REQUESTED', fetchUser);
}
export default mySaga;
import { takeEvery } from 'redux-saga'
import { call, put, select } from 'redux-saga/effects'
import { getBooksSuccess, getBooksFailure } from './actions';
const selectBooks = (state) => state.books;
function* fetchBooks(action) {
// check to see if books exist in state already
const books = yield select(selectBooks);
if (!books) {
try {
// call booksApi.getBooks to get books from API
const _books = yield call(booksApi.getBooks);
// dispatch GET_BOOKS_SUCCESS
yield put(getBooksSuccess(_books));
} catch (e) {
// if request fails, dispatch GET_BOOKS_FAILURE
yield put(getBooksFailure(e.message));
}
}
}
// "listener" function that will listen for GET_BOOKS actions
function* booksSaga() {
yield* takeEvery('GET_BOOKS', fetchBooks);
}
export default booksSaga;
// Actions
// create a thunked action
const getBooks = () => ({
type: 'GET_BOOKS'
});
const getBooksSuccess = (books) => ({
type: 'GET_BOOKS_SUCCESS',
payload: books
});
const getBooksFailure = (error) => ({
type: 'GET_BOOKS_FAILURE',
payload: error
});
import test from 'tape';
import { put, call } from 'redux-saga/effects'
import { delay } from 'redux-saga'
import { incrementAsync } from './sagas'
test('incrementAsync Saga test', (assert) => {
const gen = incrementAsync()
assert.deepEqual(
gen.next().value,
call(delay, 1000),
'incrementAsync Saga must call delay(1000)'
)
assert.deepEqual(
gen.next().value,
put({type: 'INCREMENT'}),
'incrementAsync Saga must dispatch an INCREMENT action'
)
assert.deepEqual(
gen.next(),
{ done: true, value: undefined },
'incrementAsync Saga must be done'
)
assert.end()
});
import { put, call } from 'redux-saga/effects'
import { delay } from 'redux-saga'
export function* incrementAsync() {
// use the call Effect
yield call(delay, 1000)
yield put({ type: 'INCREMENT' })
}
Selecting state, especially derived state, is painful and non-performant
// SomeComponent.js
...
const mapStateToProps = (state) => {
const books = state.books; // Immutable map
const filterText = state.filterText;
return {
filteredBooks: books.filter((book) => {
return book.name.includes(filterText);
})
};
};
...
export default connect(mapStateToProps)(SomeComponent);
const mapStateToProps = (state) => {
const books = state.books; // Immutable map
const filterText = state.filterText;
return {
filteredBooks: books.filter((book) => {
return book.name.includes(filterText);
})
};
};
// selectors.js
import { createSelector } from 'reselect';
const booksSelector = (state) => state.books;
const filterTextSelector = (state) => state.filterText;
export const filteredBooksSelector = createSelector(
booksSelector,
filterTextSelector,
(books, filterText) =>
books.filter((book) => {
return book.name.includes(filterText);
})
);
// SomeComponent.js
import { filteredBooksSelector } from './selectors.js';
...
const mapStateToProps = (state) => {
return {
filteredBooks: filteredBooksSelector(state)
};
};
...
export default connect(mapStateToProps)(SomeComponent);
Too much boilerplate code and too reliant on string constants
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
export const EDIT_TODO = 'EDIT_TODO';
export const COMPLETE_TODO = 'COMPLETE_TODO';
export const COMPLETE_ALL = 'COMPLETE_ALL';
export const CLEAR_COMPLETED = 'CLEAR_COMPLETED';
import * as types from './constants';
const addTodo = (text) => ({
type: types.ADD_TODO,
payload: { text }
});
const removeTodo = (id) => ({
type: types.REMOVE_TODO,
payload: { id }
});
const editTodo = (id, text) => ({
type: types.EDIT_TODO,
payload: { id, text }
});
const completeTodo = (id) => ({
type: types.COMPLETE_TODO,
payload: { id }
});
const completeAll = () => ({
type: types.COMPLETE_ALL
});
const clearCompleted = () => ({
type: types.CLEAR_COMPLETED
});
import {
ADD_TODO, DELETE_TODO, EDIT_TODO, COMPLETE_TODO,
COMPLETE_ALL, CLEAR_COMPLETED
} from './constants';
const initialState = [
{
text: 'Use Redux',
completed: false,
id: 0
}
]
export default function todos(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return [
{
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.text
},
...state
]
case DELETE_TODO:
return state.filter(todo =>
todo.id !== action.id
)
case EDIT_TODO:
return state.map(todo =>
todo.id === action.id ?
{ ...todo, text: action.text } :
todo
)
case COMPLETE_TODO:
return state.map(todo =>
todo.id === action.id ?
{ ...todo, completed: !todo.completed } :
todo
)
case COMPLETE_ALL:
const areAllMarked = state.every(todo => todo.completed)
return state.map(todo => ({
...todo,
completed: !areAllMarked
}))
case CLEAR_COMPLETED:
return state.filter(todo => todo.completed === false)
default:
return state
}
}
Constants
Actions
Reducer
Simpler, more consistent way to create redux action creators
const addTodo = createAction('Add todo');
addTodo('content');
// return {
// type: '[1] Add todo',
// payload: 'content'
// }
const editTodo = createAction(
'Edit todo',
(id, content) => ({id, content})
);
editTodo(42, 'the answer');
// return {
// type: '[2] Edit todo',
// payload: {
// id: 42,
// content: 'the answer'
// }
// }
const serializeTodo = createAction('SERIALIZE_TODO');
serializeTodo(1);
// return { type: 'SERIALIZE_TODO', payload: 1 }
Simpler, more consistent way to create redux reducers
const increment = createAction();
const add = createAction();
// First pattern
const reducerMap = createReducer({
[increment]: (state) => state + 1,
[add]: (state, payload) => state + payload
}, 0);
// Second pattern
const reducerFactory = createReducer(function (on, off) {
on(increment, (state) => state + 1);
on(add, (state, payload) => state + payload);
// 'off' remove support for a specific action
}, 0);
import { createAction } from 'redux-act';
const addTodo = createAction('ADD_TODO');
const removeTodo = createAction('REMOVE_TODO');
const editTodo = createAction('EDIT_TODO');
const completeTodo = createAction('COMPLETE_TODO');
const completeAll = createAction('COMPLETE_ALL');
const clearCompleted = createAction('CLEAR_COMPLETED');
import {
addTodo, deleteTodo, editTodo, completeTodo,
completeAll, clearCompleted
} from './actions';
import { createReducer } from 'redux-act';
const initialState = [
{
text: 'Use Redux',
completed: false,
id: 0
}
]
export default createReducer({
[addTodo]: (state, { text }) => [
{
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text
},
...state
],
[deleteTodo]: (state, { id }) => state.filter(todo => todo.id !== id),
[editTodo]: (state, { id, text }) => state.map(todo =>
todo.id === id ?
{ ...todo, text } :
todo
),
[completeTodo]: (state, { id }) => state.map(todo =>
todo.id === id ?
{ ...todo, completed: !todo.completed } :
todo
),
[completeAll]: (state) => {
const areAllMarked = state.every(todo => todo.completed)
return state.map(todo => ({
...todo,
completed: !areAllMarked
}))
},
[clearCompleted]: (state) => state.filter(todo => todo.completed === false)
}, initialState)
Actions
Reducer
export const ADD_TODO = 'ADD_TODO';
export const REMOVE_TODO = 'REMOVE_TODO';
export const EDIT_TODO = 'EDIT_TODO';
export const COMPLETE_TODO = 'COMPLETE_TODO';
export const COMPLETE_ALL = 'COMPLETE_ALL';
export const CLEAR_COMPLETED = 'CLEAR_COMPLETED';
import * as types from './constants';
const addTodo = (text) => ({
type: types.ADD_TODO,
payload: { text }
});
const removeTodo = (id) => ({
type: types.REMOVE_TODO,
payload: { id }
});
const editTodo = (id, text) => ({
type: types.EDIT_TODO,
payload: { id, text }
});
const completeTodo = (id) => ({
type: types.COMPLETE_TODO,
payload: { id }
});
const completeAll = () => ({
type: types.COMPLETE_ALL
});
const clearCompleted = () => ({
type: types.CLEAR_COMPLETED
});
import {
ADD_TODO, DELETE_TODO, EDIT_TODO, COMPLETE_TODO,
COMPLETE_ALL, CLEAR_COMPLETED
} from './constants';
const initialState = [
{
text: 'Use Redux',
completed: false,
id: 0
}
]
export default function todos(state = initialState, action) {
switch (action.type) {
case ADD_TODO:
return [
{
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.text
},
...state
]
case DELETE_TODO:
return state.filter(todo =>
todo.id !== action.id
)
case EDIT_TODO:
return state.map(todo =>
todo.id === action.id ?
{ ...todo, text: action.text } :
todo
)
case COMPLETE_TODO:
return state.map(todo =>
todo.id === action.id ?
{ ...todo, completed: !todo.completed } :
todo
)
case COMPLETE_ALL:
const areAllMarked = state.every(todo => todo.completed)
return state.map(todo => ({
...todo,
completed: !areAllMarked
}))
case CLEAR_COMPLETED:
return state.filter(todo => todo.completed === false)
default:
return state
}
}
Constants
Actions
Reducer
Honorable Mentions
redux-actions
@kanzelm3