-
Notifications
You must be signed in to change notification settings - Fork 2
Preparing your Data for Facets
redux-facet
, at its core, is a simple concept: tag actions with metadata to help track them from the view they originated from. By utilizing this metadata in a variety of ways, we unlock the ability to reuse many generic behaviors in our Redux code across all of our app's experiences.
Enabling code reuse is not always automatic, though, and may require some work to integrate into an existing codebase. This article discusses how to align the shape of a Redux store with the needs of Facets.
Redux apps develop and grow differently depending on requirements, development style, etc. So while we can't anticipate how other developers are shaping their store data, we can give examples from our own project history.
When we started using Redux in the early stages, keys would be added to sections as needed, creating a very 'organic' kind of shape:
{
posts: {
list: [
{ id: 'post1', title: 'Foo', content: 'bar' },
{ id: 'post2', title: 'Food', content: 'thud' },
],
activePost: { id: 'post2', title: 'Food', content: 'thud' },
loading: true,
error: 'Could not load post list',
createError: 'Title must not be blank',
page: 2,
pageSize: 10,
filters: {
title: 'Fo',
type: 'userCreated',
},
},
}
Without clear guidelines about what should be in a store section and how the data should be structured, we wound up with a shape which is fairly easy to follow, but contains many separate code concerns. Plus, because it's not normalized, there's duplication of state in activePost
. And of course, where the reducer is cluttered with concerns, so are the actions:
[
'POSTS_LIST_REQUEST',
'POSTS_LIST_COMPLETE',
'POSTS_LIST_FAILED',
'POSTS_CREATE_REQUEST',
'POSTS_CREATE_COMPLETE',
'POSTS_CREATE_FAILED',
'POSTS_CLEAR_LIST_ERROR',
'POSTS_CLEAR_CREATE_ERROR',
'POSTS_SET_PAGE',
'POSTS_SET_FILTERS',
]
You can start to see where the idea for redux-facet came from when you have two actions, CLEAR_LIST_ERROR
and CLEAR_CREATE_ERROR
, which essentially have the same logic... and that only within one set of action creators.
From our naïve example, let's move further down the road to something with more structure.
{
posts: {
data: {
post1: { id: 'post1', title: 'Foo', content: 'bar' },
post2: { id: 'post2', title: 'Food', content: 'thud' },
},
meta: {
activePostId: 'post2',
loading: true,
error: 'Could not load post list',
createError: 'Title is required',
page: 2,
pageSize: 10,
filters: {
title: 'Fo',
type: 'userCreated',
},
},
},
}
This store is more normalized, with posts being accessible by id. We've moved all non-data state into meta
to keep things separate. But meta
is still a junk-drawer of concerns, and our actions have not changed. This is where redux-facet steps in and helps to extract some of these common behaviors.
There are other problems with this approach, too. Let's suppose that posts are displayed in two different views in our app: the main Feed, and the user's own Profile. In the Feed, we want to filter the list to show posts from everyone, and on the Profile, we want to only show posts created by the user. How can we effectively represent this with our current store? Our filters are essentially a singleton, and when we switch between the views we will need to change them according to which view we are on. This, of course, would break down if we tried to render both Feed and Profile on the same page.
The traditional Redux approach might be to create feedFilters
and profileFilters
, which adds more clutter to our reducer and actions. Or, for a more forward-thinking solution, we could extract profilePosts
and feedPosts
from our posts
section of the store, which would contain separated filter and pagination information. And, in fact, that's what redux-facet does... but in a way which makes it easier to reuse common code.
{
posts: {
data: {
post1: { id: 'post1', title: 'Foo', content: 'bar' },
post2: { id: 'post2', title: 'Food', content: 'thud' },
},
meta: {
loading: true,
},
},
facets: {
feedPosts: {
filters: [
{ type: 'autor', value: 'all' },
],
page: 1,
pageSize: 30,
error: 'Could not load posts list',
},
profilePosts: {
filters: [
{ type: 'author', value: 'activeUser' },
],
page: 0,
pageSize: 10,
error: 'Title is required',
},
},
}
We've pared down the posts section of our store to include only common data. posts
acts as our canonical 'database' of our post data. This includes loading metadata, since that's shared state tied directly to the data itself. But for all other concerns, we can split things out into individual facets.
When the time comes to render, each facet will reference the same posts
data to do its computations. It will then refer to its own state to gather filter and pagination information and apply it to the global data set. This computes the final view of the data which the user will see and interact with.
Note that redux-facet tooling will actually create and manage our facets
store section for us. In most cases, we won't need to write any code for those actions, reducers, or selectors.
Treating our store data as if it were a local database has some implications for how we update and 'query' it.
We still want to use the _REQUEST
, _COMPLETE
, _FAILED
action structure to track our asynchronous operations as we interact with our API.
The catch comes when we start applying transformations like pagination and filtering from our facets. We want to ensure that the data in our store accurately reflects the view of our data source we are presenting. That is, if we request a page size of 100, we want to have 100 items in our data store.
To do this requires some 'optimism' on the part of our application logic. When the user visits a view which has a page size of 100, for instance, we will be dispatching two actions:
{
type: '@@redux-facet-pagination/SET_PAGE',
payload: { page: 0, pageSize: 100 },
meta: { facetName: 'mainFeed' },
}
{
type: 'LIST_POSTS_REQUEST',
payload: { page: 0, pageSize: 100 },
}
The first is part of redux-facet-pagination
, and it simply updates the pagination state in our facet. The facet will use this state to calculate a page view out of our main post data collection.
The second will be specific to our application. The action indicates we should make an API request to list posts, indicating the page size and current page as well. We will use this action to construct an API request, perhaps something like GET /api/posts?page=0&pageSize=100
. In this example, let's assume that our API by default returns items in a specific order.
When the response for our API request comes in, we will merge the data into our main data store.
const postsReducer = (state, action) => {
switch (action.type) {
/* ... */
'POSTS_LIST_COMPLETE': ({
...state,
// merge in our new posts by id, overwriting duplicates
data: action.payload.posts
.reduce((collection, post) => ({ ...collection, [post.id]: post }), state.data),
}),
/* ... */
}
}
Now we know that we have at least the first 100 posts in our data store. Depending on what's been happening in our application, we may have many more. But for the purpose of our facet, those 100 are all we need to compute the view we want to show to the user. With all the required data loaded, we can simply render our facet with the pagination enabled.
// our post selector converts the map collection into a list
// and sorts by timestamp
// Note, you could also use redux-facet-filters for your sorting.
const selectPosts = state => Object.values(state[posts])
.sort((a, b) => a.timestamp - b.timestamp);
const MainFeed = compose(
facet('mainFeed', mapDispatchToProps),
withPaginatedData(selectPosts), // from redux-facet-pagination
)(FeedView);
<MainFeed pageSize={100} />