A React Journey
My approach to rendering data with React has evolved quite a bit over the past year and I’d like to take some time to go catalog that journey. This will primarily focus on fetching, transforming, and rendering data with React and not on setting up a build, styling, testing, or any of the other tools we end out using in a production product. I am not trying to be prescriptive with my current approach as much as I want to show my process as I’ve tried different common approaches, and hopefully it will help people make an informed choice.
As an example, I will use a simple todo page with the following features:
- on page load fetch a list of todos and display them
- an input box with a button that adds a new todo
- an input that filters the list as you type
In each example I assume there is a TodoService for interacting the server.
*disclaimer* I haven’t run this code so it may not be perfect
#1: Dump it all in React
For a simple page or quick prototype this approach ties all of your logic to your presentation layer. It has very few dependencies and easy to follow if kept simple. In my experience a codebase written this way will be awful once it gets larger and you try to scale a team.
export default class TodosList extends Component {state = {
todos: [],
filteredTodos: [],
newTodoText: '',
filterText: ''
};
render() {
const {
todos,
filteredTodos,
newTodoText,
filterText
} = this.state;
return (
<div>
<button onClick={this.addTodo}>add</button>
<input type='text'
value={newTodoText}
onChange={this.updateTodoText}/>
<label>
Filter
<input type='text'
value={filterText}
onChange={this.updateFilterText}/>
</label>
<ol className={styles.list}>
{
(filteredTodos || todos).map(
todo => <li key={todo.id}>{todo.text}</li>
)
}
</ol>
</div>
);
}
componentDidMount() {
TodosService.fetch().then(todos => this.setState({todos});
}
updateTodoText = e => {
this.setState({newTodoText: e.currentTarget.value});
}
addTodo = () => {
const { newTodoText, todos } = this.state; if (newTodoText.length) {
TodosService.addTodo({text: newTodoText}).then(
newTodo => this.setState({todos: todos.concat(newTodo)})
);
this.setState({todoText: ''});
}
}updateFilterText = e => {
const filterText = e.currentTarget.value;
const filteredTodos = (searchText.length > 0)
? this.state.todos.filter(todo => todo.text.includes(filterText))
: null;
this.setState({ filterText, filteredTodos });
}
}
#2: Dump all the state in Redux
Redux has a lot of momentum in the community that provides lots of resources and tooling. The biggest problem is calculating derived data (ex./ todos = filterText ? todos.filter(…) : todos) is really annoying if done completely in redux. I also personally don’t like the action model of dispatching actions using a string type (I’d rather call a method). There’s quite a few arguments for and against using a single store vs multiple stores so I won’t go deep into that discussion, but for a single store Redux is hard to beat. I also highly recommend redux-devtools.
redux.js
const TODOS_FETCH_SUCCESS = 'TODOS_FETCH_SUCCESS';
const TODOS_ADD_SUCCESS = 'TODOS_ADD_SUCCESS';
const TODOS_SET_NEW_TODO_TEXT = 'TODOS_SET_NEW_TODO_TEXT';
const TODOS_SET_FILTER_TEXT = 'TODOS_SET_FILTER_TEXT';// using redux-thunk
export const fetchTodos = () => {
return (dispatch, getState) => {
TodosService.fetch().then(
todos => dispatch({ type: TODOS_FETCH_SUCCESS, payload: todos })
);
}
}export const addTodo = (text) => {
if(text && text.length) {
return (dispatch, getState) => {
TodosService.addTodo({text: newTodoText}).then(newTodo =>
dispatch({
type: TODOS_ADD_SUCCESS, payload: newTodo
}))
);
}
}
}export const setNewTodoText = (text) =>
({ type: TODOS_SET_NEW_TODO_TEXT, payload: text });export const setFilterText = (text) =>
({ type: TODOS_SET_FILTER_TEXT, payload: text });const initialState = {
todos: [],
filteredTodos: [],
newTodoText: '',
filterText: ''
};export function reducer(state = initialState, action) {
switch(action.type) {
case TODOS_FETCH_SUCCESS:
return {
...state,
todos: action.payload
}; case TODOS_ADD_SUCCESS:
return {
...state,
todos: todos.concat(action.payload),
newTodoText: ''
}; case TODOS_SET_NEW_TODO_TEXT:
return {
...state,
newTodoText: action.payload
}; case TODOS_SET_FILTER_TEXT:
return {
...state,
filteredTodos: (action.payload.length > 0)
? state.todos.filter(todo => todo.text.includes(action.payload))
: null;
filterText: action.payload
};
default:
return state;
}
}
TodosList.jsx
class TodosList extends Component { propTypes = {
todos: PropTypes.array.isRequired,
filteredTodos: PropTypes.array,
newTodoText: PropTypes.string.isRequired,
filterText: PropTypes.string.isRequired,
fetchTodos: PropTypes.func.isRequired,
addTodo: PropTypes.func.isRequired,
setNewTodoText: PropTypes.func.isRequired,
setFilterText: PropTypes.func.isRequired
};
render() {
const {
todos,
filteredTodos,
newTodoText,
filterText
} = this.props;
return (
<div>
<button onClick={this.addTodo}>add</button>
<input type='text'
value={newTodoText}
onChange={this.updateTodoText}/>
<label>
Filter
<input type='text'
value={filterText}
onChange={this.updateFilterText}/>
</label>
<ol className={styles.list}>
{
(filteredTodos || todos).map(
todo => <li key={todo.id}>{todo.text}</li>
)
}
</ol>
</div>
);
}
componentDidMount() {
this.props.fetchTodos();
}
updateTodoText = e => {
this.props.setNewTodoText(e.currentTarget.value);
}
addTodo = () => {
const { addTodo, newTodoText } = this.props;
addTodo(newTodoText);
} updateFilterText = e => {
this.props.setFilterText(e.currentTarget.value);
}
} // using react-redux
export default connect(
({ todos, filteredTodos, newTodoText, filterText }) =>
({ todos, filteredTodos, newTodoText, filterText }),
{
fetchTodos,
addTodo,
setNewTodoText,
setFilterText
}
);
#3: State in Redux + Derived State with Reselect
Reselect really helps you be smart about how you are using Redux. It helps to separate basic state and computed values, which makes your redux store a lot simpler, helps keep your approach to business logic consistent, and memoizes calculations for efficiency. This approach works really well with PureComponents because reselect will keep or break object references correct without needing something like Immutable.
redux.js
// actions are the sameconst initialState = {
todos: [],
newTodoText: '',
filterText: ''
};export function reducer(state = initialState, action) {
switch(action.type) { // other reducers are the same
case TODOS_SET_FILTER_TEXT:
return {
...state,
filterText: action.payload
};
default:
return state;
}
}
TodosList.jsx
// Component is the same as #2 redux example except we no longer
// need to pass filteredTodos, we just render the todos propconst selectFilterText = state => state.filterText;
const selectFilteredTodos = createSelector(
state => state.todos,
selectFilterText,
(todos, filterText) =>
(filterText.length > 0)
? todos.filter(todo => todo.text.includes(action.payload))
: todos;
);// using react-redux
export default connect(
createStructuredSelector({
todos: selectFilteredTodos,
newTodoText: state => state.newTodoText,
filterText: selectFilterText
}),
{
fetchTodos,
addTodo,
setNewTodoText,
setFilterText
}
);
#4: State in Higher Order Components with Recompose + Derived State with Reselect
Once you move to Redux & Reselect you see how little state you really need to store in Redux and how simple that store is, so using higher order components with Recompose becomes a nice api for storing state and running selectors. Creating custom higher order components is easy and the end result becomes quite readable. The biggest issue I find is all the data gets added on to a big props object and it can become hard to tell where the data comes from without good documentation (ex./ ‘fetchData’ just appears magically from the withFetch component). Also, connecting multiple containers to the same state isn’t as straight forward as connecting to Redux.
// Component is the same as #2 redux example except we not longer
// need to pass filteredTodos, we just render the todos prop.
// We also no longer need to call fetch in the componentconst addTodo = props => text => TodosService.addTodo(text)
.then(todo => props.setAddedTodos(props.addedTodos.concat(todo)))const selectFilteredTodos = createSelector(
props => props.filterText,
filterText => {
(filterText.length > 0)
? props.todos.filter(todo => todo.text.includes(action.payload))
: null;
}
);const selectTodos = createSelector(
props => props.fetchData,
props => props.addedTodos,
selectFilteredTodos,
(fetchedTodos, addedTodos, filteredTodos) =>
filteredTodos ? filteredTodos : fetchedTodos.concat(addedTodos)
);export default compose(
// simple higher order component that passes 'fetchData' prop
// after resolving the promise
withFetch(TodosService.fetch),
withState('addedTodos', 'setAddedTodos', []),
withState('newTodoText', 'setNewTodoText', ''),
withState('filterText', 'setFilterText', ''),
withHandlers({ addTodo }),
withProps(createStructuredSelector({
todos: selectTodos
})),
);
#5: State and Derived State with RxJS
After working with recompose and reselect it became apparent that I was just using React as a poor mans observable, and reselect as a way of manipulating the results observable streams. So why not use real observables with a huge set of utilities? At this point I’m using multiple stores with regular class properties and methods that we can compose to force our components to re-render whenever an observable changes. The ‘combineLatest’ function keeps our object references in tact just like with reselect. These stores become highly re-usable and can subscribe to each other. It’s quite a big departure from the Redux approach, and some people may find multiple stores subscribing to each other difficult to follow, but I find that when done correctly it is extremely powerful. RxJS also has first class Typescript support (which I’ll write about in another post).
TodosList.js
// export for testing
export class TodosListStore {
todos$ = new BehaviorSubject([]);
todosService = TodosService;fetchTodos = () =>
this.todosService.find().then((todos) => this.todos$.next(todos));
addTodo = todo => this.todosService.create(todo).then(
addedTodo => this.todos$.next(this.todos$.value.concat(addedTodo))
);
constructor(todos$, todosService) {
this.todos$ = todos$ || this.todos$;
this.todosService = todosService || this.todosService;
}
}export const TodosList = new TodosListStore();
TodosSearch.js
// export for testing
export class TodosSearchStore {
filterText$ = new BehaviorSubject('');
todos$ = TodosList.todos$;
filteredTodos$ = Observable.combineLatest(
this.todos$,
this.searchText$,
(todos, searchText) =>
todos.filter(todo => (searchText.length > 0)
? todo.text.includes(searchText)
: true
)
);
setFilterText = text => this.filterText$.next(text);
constructor(filterText$, todos$) {
this.filterText$ = filterText$ || this.filterText$;
this.todos$ = todos$ || this.todos$;
}
}export const TodosSearch = new TodosSearchStore();
TodosList.jsx
// Component is the same as #2 redux example except we not longer
// pass filteredTodos and we just render the todos prop// https://gist.github.com/slively/d2f919043f7e1e587ff444f7717e10d0
export default withObservable(
// https://github.com/staltz/combineLatestObj
combineLatestObj({
todos$: TodosSearch.filteredTodos$,
fetchTodos: TodosList.fetchTodos,
addTodo: TodosList.addTodo,
filterText$: TodosSearch.filterText$,
setFilterText: TodosSearch.setFilterText
})
)(TodosList);