About the Speaker
Senior Developer at The Weather Company, an IBM Business
Works on the Mobile Web team, moonlights as contributor on the Core/Decoupling team
Loves watching NFL and playing ultimate frisbee
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)
Actions/Action Creators
{
type: 'ADD_TODO',
payload: 'Pick up dry cleaning'
}
An action is just an object that defines an action that was taken in your application
function addTodo(text) {
if (!text) {
return {
type: 'ADD_TODO',
payload: new Error('Todo text is required'),
error: true
};
}
return {
type: 'ADD_TODO',
payload: text
};
}
An action creator is a reusable function that creates an action of a given type
May contain PURE business logic
An action MUST NOT include properties other than type, payload, error, and meta.
An action MUST
An action MAY
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);
Another (More Advanced) Example
/**
* moduleA.connector.js
*/
import { updateText } from '../actions';
const mapStateToProps = (state) => ({
text: state.text
});
const mapDispatchToProps = (dispatch) => ({
updateText: (evt) => dispatch(updateText(evt.target.text))
});
// only exporting the connect function
export default connect(mapStateToProps, mapDispatchToProps);
/**
* moduleB.connector.js
*/
const mapStateToProps = (state) => ({
headline: state.headline
});
// only exporting the connect function
export default connect(mapStateToProps);
/**
* ModuleC.jsx
*/
export const ModuleC = ({ headline, text, updateText }) => (
<div>
<h1>{headline}</h1>
<DumbComponent text={text} onChange={updateText} />
</div>
);
export default compose(moduleAConnector, moduleBConnector)(ModuleC);
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 newBooks = yield call(booksApi.getBooks);
// dispatch GET_BOOKS_SUCCESS
yield put(getBooksSuccess(newBooks));
} 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' })
}
Working w/ Decoupled Modules
System.import() tells webpack to make a js chunk and load that chunk when requested
// src/components/index.js
export const TwcHeader = () => System.import('@twc/header');
Very soon, decoupled modules will live in a mono-repo that will be considered the module registry
Dynamic imports means that we need to take steps to register the module after it's chunk is loaded
// module registration interface
async function registerModule({
injectReducer,
injectReducerWithKey,
runSaga,
selectorFactory, // create a selector using the reducer key from consuming app
// the properties below are subject to be removed
selectors, // selectors from consuming app
actions, // actions from consuming app
props // props from consuming app
}) {
// ...use the interface to register module
// must return a promise that resolves with component to render
}
Since the module is being loaded dynamically, we must inject the reducer into the store during module registration
import reducer from './ContentMedia.reducer';
async function registerModule({
// ...
injectReducer
}) {
// add reducer to redux store
injectReducer(reducer);
// ...finish module registration
}
// redux store
{
// ...other reducer keys,
'ContentMedia/as1d-231d-r12e-231r-adw3': reducer
}
Sometimes a module is shared and it's state needs to be accessed by other modules, in this case we need a static key
import reducer from './TwcHeader.reducer';
async function registerModule({
// ...
injectReducerWithKey
}) {
// add reducer to redux store with static key
injectReducerWithKey('twcHeader', reducer);
// ...finish module registration
}
// redux store
{
// ...other reducer keys,
'twcHeader': reducer
}
export default function* twcHeaderSaga() {
yield call(loadTwcHeaderData);
// spawn watchers
yield spawn(watchChangeUnit);
}
import saga from './TwcHeader.saga';
async function registerModule({
// ...
runSaga
}) {
// run saga before returning component
await runSaga(saga).done;
// return component to render
}
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);
Brief overview and some gotchas
// blocking call
// must complete before saga execution continues
yield call(someTask);
// attached fork
// saga execution continues
// saga will not complete until forked task completed
yield fork(someTask);
// detached fork
// saga execution continues
// saga completion is not blocked by forked task
yield spawn(someTask);
call
fork
spawn
blocking task
non-blocking task, attached
non-blocking task, detached
yield call(task1);
yield call(task2); // must wait for task1
yield call(task3); // must wait for task2
yield [
call(task1),
call(task2),
call(task3)
];
// waits for all 3 tasks to complete, but runs them concurrently
// listens for the first ADD_TODO action and continues
const action = yield take('ADD_TODO');
// listens every ADD_TODO action and spawns a task
yield takeEvery('ADD_TODO', taskToSpawn);
// listens the latest ADD_TODO action and spawns a task
// if a previously spawned task is still running, it is cancelled
yield takeLatest('ADD_TODO', taskToSpawn);
describe('mySaga', () => {
let saga;
let mockUser;
before(() => {
saga = mySaga();
mockUser = {
id: '123',
name: 'Unit Tester'
};
});
it('should select user data from state', () => {
expect(saga.next().value).to.deep.equal(
select(userDataSelector)
);
});
it('should fetch new user data', () => {
expect(saga.next(mockUser).value).to.deep.equal(
call(fetchUserData, mockUser.id)
);
});
});
function* mySaga() {
const user = yield select(userDataSelector);
yield call(fetchUserData, user.id);
}
@kanzelm3