nion is our best attempt at addressing the main issues that come with developing a complex redux web application. It's an opinionated solution to the problems we faced building a complex React web application at Patreon.
A canonical example of a challenging data management problem is a stream. Our stream is a paginated list of posts, which can be liked and unliked, and have comments and replies, which are both also independently paginated. This presents a number of challenges that have guided the design process of nion.
- How do I paginate a list?
- How do I optimistically update data?
- How do I pass off management of data from one component to another?
- How do I do all of this while keeping my code clear and consistent?
In the following walkthrough, we'll be building a fully functional stream using nion, thus learning how nion allows us to solve the problems involved in a clean and elegant way.
- a stream of posts
- loading data on mount
- include and fields
- pagination
- render post details
- like a post
- mutating data
- unlike a post
- mutating data optimistically
- loading more comments
- loading replies
Let's begin by fetching and rendering a stream of posts. We begin by wrapping a Stream component with the nion decorator function. The nion decorator accepts a map of declarations as its parameters, which tell nion what data to manage and where to load it from. What data nion will manage is determined by the dataKey, and where it will load it from is determined by the endpoint.
In the example below, our dataKey is "stream"
, and our endpoint is the output of the function buildUrl("/stream")
. (note - we're using our own custom function to construct a fully-formed url, and nion needs a fully-formed url to fetch data from) In this most basic case, the dataKey is determined by the key of the declaration passed into the nion decorator. The nion decorator creates a set of selectors and actions based on this information and passes the selected data and actions into the child component as props mapped to the key of the declarative.
import React, { Component } from 'react'
import nion, { exists } from 'nion'
import { buildUrl } from 'utilities/json-api'
import Post from './Post'
@nion({
stream: {
endpoint: buildUrl('/stream'),
},
})
class Stream extends Component {
componentDidMount() {
const { stream } = this.props.nion
stream.actions.get()
}
render() {
const { stream } = this.props.nion
const { isLoading } = stream.request
return (
<Card>
{exists(stream.data) &&
stream.data.map((post, index) => (
<Post key={index} post={post} />
))}
{loading ? <LoadingSpinner /> : null}
</Card>
)
}
}
The nion decorator supplies a nion
prop to the child component, with a stream
subprop. This stream subprop consists of an object combining the result of selectData('stream')
onto a data
property, with requests
and actions
properties
The requests
property contains the result of applying the selector selectRequest('stream')
. It contains all information regarding the network status of the given request. The actions
property contains methods that dispatch redux actions corresponding to a given resource's REST actions.
Notice the exists
method we're using to check existence of the stream data. This is a convenience method for checking whether or not any data has been attached to the dataKey, since falsiness bugs in selected data can lead to some strange bugs.
import React, { Component } from 'react'
class Post extends Component {
render() {
const { post } = this.props
return (
<Card>
<strong>Post id: {post.id}</strong>
<br />
</Card>
)
}
}
For brevity's sake, we'll omit importing a few files that are used throughout the examples. These are
import Card from 'components/Card'
import Button from 'components/Button'
import LoadingSpinner from 'components/LoadingSpinner'
It's a very common pattern to load data for a component when that component mounts. The nion decorator exposes a shorthand fetchOnInit
property for handling this automatically. Note that this functionality is hard-wired to dispatch a get
action. For more complex, non-standard behavior it's best to directly use the component lifecycle methods.
import React, { Component } from 'react'
import nion, { exists } from 'libs/nion'
import { buildUrl } from 'utilities/json-api'
import Post from './Post'
@nion({
stream: {
endpoint: '/stream',
fetchOnInit: true,
},
})
class Stream extends Component {
render() {
const { stream } = this.props.nion
const { isLoading } = stream.request
return (
<Card>
{exists(stream.data) &&
stream.data.map((post, index) => (
<Post key={index} post={post} />
))}
{loading ? <LoadingSpinner /> : null}
</Card>
)
}
}
JSON API allows us to specify which fields and related entities are fetched in a request. We can specify these JSON API parameters by passing a fully formed JSON API url with these url-encoded fields to the endpoint
parameter of the declarative. We can include these data requirements directly inside the declaration, but we import them from an external file here for clarity.
import React, { Component } from 'react'
import nion, { exists } from 'libs/nion'
import { buildUrl } from 'utilities/json-api'
import { streamInclude, streamFields } from './data-requirements'
import Post from './Post'
@nion({
stream: {
endpoint: buildUrl(`/stream`, {
include: streamInclude,
fields: streamFields,
}),
},
})
class Stream extends Component {
render() {
const { stream } = this.props.nion
const { isLoading } = stream.request
return (
<Card>
{exists(stream.data) &&
stream.data.map((post, index) => (
<Post key={index} post={post} />
))}
{loading ? <LoadingSpinner /> : null}
</Card>
)
}
}
Notice that we're importing the include
and fields
parameters from an external file. This is a handy pattern for making the decorator more readable, and allows us to share commonly required fields across decorated components.
export const streamInclude = [
'recent_comments.commenter',
'recent_comments.parent',
'recent_comments.post',
'recent_comments.first_reply.commenter',
'recent_comments.first_reply.parent',
'recent_comments.first_reply.post',
]
export const streamFields = {
comment: [
'body',
'created',
'deleted_at',
'is_by_patron',
'is_by_creator',
'vote_sum',
'current_user_vote',
'reply_count',
],
post: ['comment_count'],
user: ['image_url', 'full_name', 'url'],
}
export const commentIncludes = [
'commenter',
'parent',
'post',
'first_reply.commenter',
'first_reply.parent',
'first_reply.post',
]
Since our JSON-API interface uses a simple, cursor-based pagination system across API resources, the nion decorator can handle pagination in a consistent and elegant way.
Paginated resources are managed by simply setting the paginated
parameter of the declarative, which tells the nion decorator to expose a few pagination-related properties to the child component. These pagination behaviors are fully defined by the nion API module, which can be customized to handle any pagination system. The main focus is the next
action method, which is simply a get
action method curried with the next page cursor returned from the initial request to the paginated resource's endpoint. In addition to dispatching this curried get
request, the next
method also dispatches the underlying NION_API_SUCCESS
redux action with a special isNextPage
meta property, which tells the internal reducers to append the result of the next
request to the existing reference rather than overwriting it.
In addition to the next action method, the nion decorator also exposes a canLoadMore
property to the request
property - This is a convenience property indicating whether or not a next cursor link exists for a given paginated ref.
import React, { Component } from 'react'
import nion, { exists } from 'libs/nion'
import { buildUrl } from 'utilities/json-api'
import { streamInclude, streamFields } from './data-requirements'
import Post from './Post'
@nion({
stream: {
endpoint: buildUrl(`/stream`, {
include: streamInclude,
fields: streamFields,
}),
fetchOnInit: true,
paginated: true,
},
})
class Stream extends Component {
render() {
const { stream } = this.props.nion
const { canLoadMore, isLoading } = stream.request
const fetchNext = () => stream.actions.next()
return (
<div>
{exists(stream.data) &&
stream.data.map((post, index) => (
<Post key={index} post={post} />
))}
{loading ? <LoadingSpinner /> : null}
{canLoadMore ? (
<Button onClick={fetchNext}>Load More</Button>
) : null}
</div>
)
}
}
Let's go ahead and create the Comments, Comment, and Like components we'll need to render out all of the information we've fetched from the stream.
import React, { Component } from 'react'
import Comments from './Comments'
class Post extends Component {
render() {
const { post } = this.props
const { commentCount, recentComments, currentUserHasLiked } = post
return (
<div>
<strong>Post id: {post.id}</strong>
<br />
{commentCount} total{' '}
{commentCount === 1 ? 'comment' : 'comments'}
{commentCount ? (
<Comments comments={recentComments} />
) : (
<span />
)}
<Like liked={currentUserHasLiked} />
</div>
)
}
}
import React, { Component } from 'react'
import Comment from './Comment'
class Comments extends Component {
render() {
const { comments } = this.props
return (
<div>
{ comments.map(comment, index) => (
<Comment comment={comment} />
)) }
</div>
)
}
}
import React, { Component } from 'react'
class Comment extends Component {
render() {
const { comment } = this.props
const body = comment.body.substring(0, 100) + '...'
const name = comment.commenter.fullName
return (
<div>
<strong>{name}: </strong>
{body}
</div>
)
}
}
import React from 'react'
const Like = ({ liked, onClick }) => (
<div onClick={onClick}>{liked ? '❤️' : 'like'}</div>
)
export default Like
In order to like a post, we need to create a new like
resource on the server corresponding to the given post. In order to manage a potential like
for each post, we need to set up an individual dataKey for each like that may be created. This will allow nion to manage the request lifecycle for each like that we may be creating for different posts.
The nion decorator can accept a function that creates a declaration
from props, and we use this syntax to create a dataKey corresponding to each like. Notice that our decorator is now passed a function that accepts the incoming post prop, using its id to create a new like:<post.id>
dataKey for each component. This means that when we call like.actions.post()
to create a new like resource for a specific post, it's request lifecycle and data is managed on the redux state tree under it's own specific dataKey.
import React, { Component } from 'react'
import nion from 'nion'
import { buildUrl } from 'utilities/json-api'
import Comments from './Comments'
@nion(({ post }) => ({
like: {
dataKey: `like:${post.id}`,
endpoint: buildUrl(`posts/${post.id}/likes`),
},
}))
class Post extends Component {
handleLikeClick = () => {
const { like } = this.props.nion
const { currentUserHasLiked } = this.props.post
if (!currentUserHasLiked) {
like.actions.post()
}
}
render() {
const { post } = this.props
const { like } = this.props.nion
const { commentCount, recentComments, currentUserHasLiked } = post
return (
<div>
<strong>Post id: {post.id}</strong>
<br />
{commentCount} total{' '}
{commentCount === 1 ? 'comment' : 'comments'}
{commentCount ? (
<Comments comments={recentComments} />
) : (
<span />
)}
<LikeHeart
liked={currentUserHasLiked}
onClick={this.handleLikeClick}
/>
</div>
)
}
}
One interesting detail in this component is that the liked
property passed to the child Like component is determined by the existence of the server-computed currentUserHasLikedproperty
of the post itself. In this case, displaying the correct like status won't work until we refresh the page or reload the stream, since we haven't changed the underlying post data when we create the new like. We'll see how to do this in the next section.
In Patreon's real-life code, the display logic for rendering post likes is actually a product of data stored on the post itself, not the presence or absence of a corresponding like. This information is represented by a prop on the post - currentUserHasLiked
. In order to properly show when a user has liked a post, we need to update the underlying data for a specific entity - in this case, our post. We have two options here: First, we can either trigger an API request that returns the updated data, allowing it to propagate through the reducers and update all data accordingly. In this exmaple, however, we'll be manually updating the data rather than through an API request. We do this with the updateEntity
nion method, which is passed in as a top-level method of the nion
prop by the nion decorator. This method takes a ref ({id, type}
tuple pointer) as the first argument, and a map of attributes to be updated on the corresponding entity.
The updateEntity
nion action is extremely simple under the hood - it simply merges the passed in attributes object into the attributes of the relevant entity.
import React, { Component } from 'react'
import nion from 'nion'
import { buildUrl } from 'utilities/json-api'
import Comments from './Comments'
@nion(({ post }) => ({
like: {
dataKey: `like:${post.id}`,
endpoint: buildUrl(`posts/${post.id}/likes`),
},
}))
class Post extends Component {
handleLikeClick = () => {
const { like, updateEntity } = this.props.nion
const { currentUserHasLiked, id: postId } = this.props.post
if (!currentUserHasLiked) {
like.actions.post().then(() => {
updateEntity(
{ id: postId, type: 'post' },
{ currentUserHasLiked: true },
)
})
}
}
render() {
const { post } = this.props
const { commentCount, recentComments, currentUserHasLiked } = post
return (
<div>
<strong>Post id: {post.id}</strong>
<br />
{commentCount} total{' '}
{commentCount === 1 ? 'comment' : 'comments'}
{commentCount ? (
<Comments comments={recentComments} />
) : (
<span />
)}
<LikeHeart
liked={currentUserHasLiked}
onClick={this.handleLikeClick}
/>
</div>
)
}
}
Notice also that all actions are functions that return a promise - this allows us to handle success and error of a given network request right next to the place where that action was called. In the example above, we're waiting until we successfully post a new like to the server before updating the underlying post data.
In order to unlike a post, we're simply deleting the corresponding like resource from the server. This is functionally identical to the post action, and, just like any REST request action, it returns a promise that allows us to follow up on the result of the request.
import React, { Component } from 'react'
import nion from 'nion'
import { buildUrl } from 'utilities/json-api'
import Comments from './Comments'
@nion(({ post }) => ({
like: {
dataKey: `like:${post.id}`,
endpoint: buildUrl(`posts/${post.id}/likes`),
},
}))
class Post extends Component {
handleLikeClick = () => {
const { like, updateEntity } = this.props.nion
const { currentUserHasLiked, id: postId } = this.props.post
if (!currentUserHasLiked) {
like.actions.post().then(() => {
updateEntity(
{ id: postId, type: 'post' },
{ currentUserHasLiked: true },
)
})
} else {
like.actions.delete().then(() => {
updateEntity(
{ id: postId, type: 'post' },
{ currentUserHasLiked: false },
)
})
}
}
render() {
const { post } = this.props
const { commentCount, recentComments, currentUserHasLiked } = post
return (
<div>
<strong>Post id: {post.id}</strong>
<br />
{commentCount} total{' '}
{commentCount === 1 ? 'comment' : 'comments'}
{commentCount ? (
<Comments comments={recentComments} />
) : (
<span />
)}
<LikeHeart
liked={currentUserHasLiked}
onClick={this.handleLikeClick}
/>
</div>
)
}
}
In order to display the information indicating the intended action to the user as quickly as possible, we need to be able to optimistically update the data corresponding to the render logic - in this case, updating the post's currentUserHasLiked
attribute. We simply use the updateEntity nion method to update the data we need to update before the corresponding request.
Since the updateEntity
method returns a promise, we can chain it with nion REST actions to handle success / failure of network actions in a clear an concise manner. This allows us to undo our optimistic updates if the corresponding response fails.
import React, { Component } from 'react'
import nion from 'nion'
import { buildUrl } from 'utilities/json-api'
import Comments from './Comments'
@nion(({ post }) => ({
like: {
dataKey: `like:${post.id}`,
endpoint: buildUrl(`posts/${post.id}/likes`),
},
}))
class Post extends Component {
setCurrentUserHasLiked = currentUserHasLiked => {
return updateEntity(
{ id: this.props.post.id, type: 'post' },
{ currentUserHasLiked },
)
}
handleLikeClick = () => {
const { like, updateEntity } = this.props.nion
const { currentUserHasLiked, id: postId } = this.props.post
if (!currentUserHasLiked) {
this.setCurrentUserHasLiked(true)
.then(() => like.actions.post())
.catch(() => setCurrentUserHasLiked(false))
} else {
this.setCurrentUserHasLiked(false)
.then(() => like.actions.delete())
.catch(() => setCurrentUserHasLiked(true))
}
}
render() {
const { post } = this.props
const { commentCount, recentComments, currentUserHasLiked } = post
return (
<div>
<strong>Post id: {post.id}</strong>
<br />
{commentCount} total{' '}
{commentCount === 1 ? 'comment' : 'comments'}
{commentCount ? (
<Comments comments={recentComments} />
) : (
<span />
)}
<LikeHeart
liked={currentUserHasLiked}
onClick={this.handleLikeClick}
/>
</div>
)
}
}
In our stream so far, we only load the 2 most recent comments for each post. However, we'd like to be able to load more comments, adding them to the list of comments already loaded. In order to achieve this advanced data management scenario, we need to create a new dataKey for the comments that is pre-populated with a reference to the already-loaded comments. We do this by using the initialRef
property of the declarative to attach a reference to the already loaded comments to the nion state.
How does this work under the hood? It's actually quite simple. When we fetch a post, we're actually fetching recentComments
as a relationship, which is just an array of {type, id}
tuples pointing to the underlying comment entities. This is how the data is stored in the entities reducer, it's only through the selectData
selector that the data becomes denormalized into a nested object. This underlying relationship pointer is a ref, and we can simply attach this to the references reducer by setting it as the initialRef
property of the declarative.
Now, when we load the component, we do two things: First, we create a new dataKey for managing the post's comments (postComments:<postId>
). Normally, the corresponding ref in the references reducer would be empty until we load it, but by providing an initialRef to the reducer, we construct a relationship between the already-existing data and the dataKey.
One final detail - notice the makeRef
function used to supply the ref to the initialRef property. This function actually constructs a ref to the data from the underlying entity store's refs. This means that when we pass in recentComments
to the makeRef function, it creates a ref with all of the corresponding pagination information brought along!
import React, { Component } from 'react'
import map from 'lodash/map'
import nion, { makeRef } from 'libs/nion'
import { buildUrl } from 'utilities/json-api'
import { commentInclude, commentStreamFields } from './dataRequirements'
import Comment from './Comment'
@nion(({ postId, recentComments }) => ({
comments: {
dataKey: `postComments:${postId}`,
endpoint: buildUrl(`/posts/${postId}/comments`, {
include: commentInclude,
fields: streamIncludes,
page: {
count: 10,
},
}),
paginated: true,
initialRef: makeRef(recentComments),
},
}))
class Comments extends Component {
render() {
const { comments } = this.props.nion
const { canLoadMore, isLoading } = comments.request
const fetchNext = comments.actions.next
return (
<div>
{map(comments.data, (comment, index) => (
<Comment comment={comment} />
))}
{isLoading ? <LoadingSpinner /> : null}
{canLoadMore ? (
<Button onClick={fetchNext}>Load More</Button>
) : null}
</div>
)
}
}
/* <Comments comments={recentComments} /> */
<Comments postId={post.id} recentComments={recentComments}
Now we'd like to load the replies to a given comment. Just like loading additional comments, we're starting with some initial data (the firstReply
). And just like loading initial comments, we're going to have to pass off this first reply as an initialRef
to the Replies component that will be handling loading the rest of the replies.
Notice that everything is almost exactly the same as above - we're passing in the firstReply
as a prop to the Replies component, which then creates an initialRef using the makeRef
function, thus "seeding" our references reducer with a pointer to the supplied first reply. The only wrinkle here is that we're manually setting the ref as a collection using the isCollection
option. This ensures that we both pass in an array to the child component, and handle the result of fetching the next page properly.
import React, { Component } from 'react'
import Replies from './Replies'
class Comment extends Component {
render() {
const { comment } = this.props
const body = comment.body.substring(0, 100) + '...'
const name = comment.commenter.fullName
const { firstReply, replyCount } = comment
return (
<div>
<strong>{name}: </strong>
{body}
{firstReply ? (
<Replies
parentCommentId={comment.id}
count={replyCount}
firstReply={firstReply}
/>
) : null}
</div>
)
}
}
import React, { Component } from 'react'
import nion, { buildUrl, exists, makeRef } from 'libs/nion'
import { commentInclude, streamFields } from './dataRequirements'
import Comment from './Comment'
@nion(({ parentCommentId, firstReply }) => ({
replies: {
dataKey: `commentReplies:${parentCommentId}`,
endpoint: buildUrl(`/comments/${parentCommentId}/replies`, {
include: commentInclude,
fields: streamFields,
}),
initialRef: makeRef(firstReply, { isCollection: true }),
},
}))
class Replies extends Component {
loadRemaining = () => {
const { replies } = this.props.nion
replies.actions.get()
}
render() {
const { count } = this.props
const { replies } = this.props.nion
const { isLoading } = replies.request
const remaining = count > 1 ? count - 1 : 0
const canLoadMore =
count > replies.data.length && remaining && !isLoading
return (
<Card>
{exists(replies.data) &&
replies.data.map(reply => {
return <Comment comment={reply} />
})}
{canLoadMore ? (
<Button onClick={this.loadRemaining}>
Load {remaining} replies
</Button>
) : null}
{isLoading ? <LoadingSpinner /> : null}
</Card>
)
}
}
the end