Wire in data
So far we created isolated stateless components –great for Storybook, but ultimately not useful until we give them some data in our app.
This tutorial doesn’t focus on the particulars of building an app so we won’t dig into those details here. But we will take a moment to look at a common pattern for wiring in data with container components.
Container components
Our TaskList
component as currently written is “presentational” (see this blog post) in that it doesn’t talk to anything external to its own implementation. To get data into it, we need a “container”.
This example uses Redux, the most popular React library for storing data, to build a simple data model for our app. However, the pattern used here applies just as well to other data management libraries like Apollo and MobX.
Add the necessary dependencies to your project with:
yarn add react-redux redux
First we’ll construct a simple Redux store that responds to actions that change the state of tasks, in a file called lib/redux.js
(intentionally kept simple):
// A simple redux store/actions/reducer implementation.
// A true app would be more complex and separated into different files.
import { createStore } from 'redux';
// The actions are the "names" of the changes that can happen to the store
export const actions = {
ARCHIVE_TASK: 'ARCHIVE_TASK',
PIN_TASK: 'PIN_TASK',
};
// The action creators bundle actions with the data required to execute them
export const archiveTask = (id) => ({ type: actions.ARCHIVE_TASK, id });
export const pinTask = (id) => ({ type: actions.PIN_TASK, id });
// All our reducers simply change the state of a single task.
function taskStateReducer(taskState) {
return (state, action) => {
return {
...state,
tasks: state.tasks.map((task) =>
task.id === action.id ? { ...task, state: taskState } : task
),
};
};
}
// The reducer describes how the contents of the store change for each action
export const reducer = (state, action) => {
switch (action.type) {
case actions.ARCHIVE_TASK:
return taskStateReducer('TASK_ARCHIVED')(state, action);
case actions.PIN_TASK:
return taskStateReducer('TASK_PINNED')(state, action);
default:
return state;
}
};
// The initial state of our store when the app loads.
// Usually you would fetch this from a server
const defaultTasks = [
{ id: '1', title: 'Something', state: 'TASK_INBOX' },
{ id: '2', title: 'Something more', state: 'TASK_INBOX' },
{ id: '3', title: 'Something else', state: 'TASK_INBOX' },
{ id: '4', title: 'Something again', state: 'TASK_INBOX' },
];
// We export the constructed redux store
export default createStore(reducer, { tasks: defaultTasks });
Then we'll update our TaskList
to read data out of the store. First let's move our existing presentational version to the file components/PureTaskList.js
and wrap with a container.
In components/PureTaskList.js
:
import * as React from 'react';
import PropTypes from 'prop-types';
import Task from './Task';
import PercolateIcons from '../constants/Percolate';
import LoadingRow from './LoadingRow';
import { FlatList, Text, SafeAreaView, View } from 'react-native';
import { styles } from '../constants/globalStyles';
export function PureTaskList({ loading, tasks, onPinTask, onArchiveTask }) {
const events = {
onPinTask,
onArchiveTask,
};
if (loading) {
return (
<SafeAreaView style={styles.ListItems}>
<LoadingRow />
<LoadingRow />
<LoadingRow />
<LoadingRow />
<LoadingRow />
<LoadingRow />
</SafeAreaView>
);
}
if (tasks.length === 0) {
return (
<SafeAreaView style={styles.ListItems}>
<View style={styles.WrapperMessage}>
<PercolateIcons name="check" size={64} color={'#2cc5d2'} />
<Text style={styles.TitleMessage}>You have no tasks</Text>
<Text style={styles.SubtitleMessage}>Sit back and relax</Text>
</View>
</SafeAreaView>
);
}
const tasksInOrder = [
...tasks.filter((t) => t.state === 'TASK_PINNED'),
...tasks.filter((t) => t.state !== 'TASK_PINNED'),
];
return (
<SafeAreaView style={styles.ListItems}>
<FlatList
data={tasksInOrder}
keyExtractor={(task) => task.id}
renderItem={({ item }) => <Task key={item.id} task={item} {...events} />}
/>
</SafeAreaView>
);
}
PureTaskList.propTypes = {
loading: PropTypes.bool,
tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
onPinTask: PropTypes.func.isRequired,
onArchiveTask: PropTypes.func.isRequired,
};
PureTaskList.defaultProps = {
loading: false,
};
export default PureTaskList;
In components/TaskList.js
:
import * as React from 'react';
import PureTaskList from './PureTaskList';
import { connect } from 'react-redux';
import { archiveTask, pinTask } from '../lib/redux';
function TaskList({ tasks, onPinTask, onArchiveTask }) {
const events = {
onPinTask,
onArchiveTask,
};
return <PureTaskList tasks={tasks} {...events} />;
}
export default connect(
({ tasks }) => ({
tasks: tasks.filter((t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'),
}),
(dispatch) => ({
onArchiveTask: (id) => dispatch(archiveTask(id)),
onPinTask: (id) => dispatch(pinTask(id)),
})
)(TaskList);
The reason to keep the presentational version of the TaskList
separate is because it is easier to test and isolate. As it doesn't rely on the presence of a store it is much easier to deal with from a testing perspective. Let's rename components/TaskList.stories.js
into components/PureTaskList.stories.js
, and ensure our stories use the presentational version:
import * as React from 'react';
import { View } from 'react-native';
import { styles } from '../constants/globalStyles';
import { storiesOf } from '@storybook/react-native';
import { task, actions } from './Task.stories';
import PureTaskList from './PureTaskList';
export const defaultTasks = [
{ ...task, id: '1', title: 'Task 1' },
{ ...task, id: '2', title: 'Task 2' },
{ ...task, id: '3', title: 'Task 3' },
{ ...task, id: '4', title: 'Task 4' },
{ ...task, id: '5', title: 'Task 5' },
{ ...task, id: '6', title: 'Task 6' },
];
export const withPinnedTasks = [
...defaultTasks.slice(0, 5),
{ id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];
storiesOf('PureTaskList', module)
.addDecorator((story) => <View style={[styles.TaskBox, { padding: 48 }]}>{story()}</View>)
.add('default', () => <PureTaskList tasks={defaultTasks} {...actions} />)
.add('withPinnedTasks', () => <PureTaskList tasks={withPinnedTasks} {...actions} />)
.add('loading', () => <PureTaskList loading tasks={[]} {...actions} />)
.add('empty', () => <PureTaskList tasks={[]} {...actions} />);
Don't forget to update storybook config file (in storybook/index.js
) to reflect these changes.
Similarly, we need to use PureTaskList
in our Jest test:
import * as React from 'react';
import { create } from 'react-test-renderer';
import PureTaskList from '../PureTaskList';
import { withPinnedTasks } from '../PureTaskList.stories';
import Task from '../Task';
describe('TaskList', () => {
it('renders pinned tasks at the start of the list', () => {
const events = { onPinTask: jest.fn(), onArchiveTask: jest.fn() };
const tree = create(<PureTaskList tasks={withPinnedTasks} {...events} />);
const rootElement = tree.root;
const listofTasks = rootElement.findAllByType(Task);
expect(listofTasks[0].props.task.title).toBe('Task 6 (pinned)');
});
});