react-quest
Declarative data fetching for universal React/Redux apps.
Overview | Documentation | Examples |
---|
; const postsResolver = key: 'posts' { return }; const withPosts = ; const Items = <div> postsdata ? postsdata : <Loading /> </div>; Items;
Introduction
A lightweight (2kb gzip) yet impressively featured library for colocating components and their data requirements. Capable of mutating remote data, performing optimistic updates, reloading data on prop changes or programmatically, and much more!
Documentation
- Prerequisites
- Setup
- Server side resolution
- Creating resolvers
- Calling resolver methods programmatically
- Deferring fetching to browser
- Fetching data on prop changes
- Transforming resolved data
- Mapping data to props
- Passing a query to the resolver
- Adding mutation methods to resolvers
- Updating remote data
- Performing optimistic updates
Prerequisites
react-quest leverages Redux to manage state and caching. Before proceeding with theup make sure you have the following packages installed:
Setup
npm install react-quest --save
Then add the react-quest reducer to your root reducer:
; const reducer = ;
Server side resolution
To server render an app that is complete with data, quests must first be resolved before their components are rendered. This means we need to reach beyond synchronous rendering solutions, like ReactDOMServer.renderToString()
, and to a renderer that can render the tree progressively. Custom renderers are super hot right now and excellent renderers like rapscallion are shaping up to solve this problem.
While the React community figures out progessive rendering you can try redux-ready, a simple solution that works well with simple trees that don't have nested quests, or react-warmup which performs a cache warmup.
Creating resolvers
Every quest must have a resolver. The resolver's job is to return some data or a promise that resolves with some data. A typical resolver might fetch some data from an API.
Every resolver must include a key
and a get()
method.
const postsResolver = key: 'posts' { return };
In this example the resolved data will be keyed against a posts
field in the redux store:
_data_: posts: loading: false complete: true error: null data: ...
To use the resolver, provide it as an option in a quest:
const withPosts = ; const Items = <div> postsdata ? postsdata : <Loading /> </div>; Items;
This creates a quest object that is set on the components props under the resolver's key name:
ItemspropTypes = posts: PropTypeshape data: PropTypeany error: PropTypestring loading: PropTypeboolean complete: PropTypeboolean get: PropTypefunction ;
A resolver can be re-used in multiple quests without causing additional fetching or duplication in the store.
Calling resolver methods programmatically
You can add as many methods to a resolver as you would like. The default get()
method is called as part of the quest's lifecyle. Additional methods can be called directly via props.
Every method in a resolver is added to the quest object for direct access. Take the following example:
const postsResolver = key: 'posts' { return } { // perform a mutation }; Posts
Both the get and create methods are added to the quest object's properties:
PostspropTypes = posts: PropTypeshape get: PropTypefunction // <-- create: PropTypefunction // <-- data: PropTypeany error: PropTypestring loading: PropTypeboolean complete: PropTypeboolean ;
Being able to call methods programmatically enables you to update your local dataset or mutate remote data. For example, the create()
method could be called on a user event (see Passing a query to the resolver), and the get()
method could be used to implement pagination or lazy loading.
Deferring fetching to the browser
By default quests will attempt to resolve their data requirements whenever they are instantiated, including on server render passes. To defer loading until the component is mounted set fetchOnServer: false
in the options block:
;
Fetching data on prop changes
To defer loading until a condition in the component's props is satisfied provide a predicate function to the fetchOnce
option:
To refetch a resource when a new condition is satisfied while a component is receiving new props, pass a predicate function to the fetchWhen
option:
Transforming resolved data
A common requirement when working with remote data sources is to be able to transform the dataset set once it has been resolved. Developers are encouraged to write functions (known as selectors in Redux land) that transform the resolved data into a new dataset. You can pass a selector function to the mapData
option and react-quest will take care of mapping the data once it arrives.
const withPostTitles = ; const PostList = <div> postsdata ? <ul> postsdata </ul> : <Loading /> </div>; PostList;
Mapping data to props
You can map data directly to a component's props. This is handy if you find yourself wanting to apply multiple selectors to the same dataset.
mapToProps
takes a prop mapping function that maps the resolved dataset to a props object. The created props object is then spread into the components own props.
const withNewPosts = ; const Items = postscompleted && <div>newPostsdata</div>; Items;
Passing a query to the resolver
Queries are a powerful construct that enable you to change the way data is resolved. Queries are passed as a parameter to resolver methods. A query
can be either a prop mapping function or a plain object:
const postsResolver = key: 'posts' { const filter = queryfilter; return }; ;
Queries can be paired nicely with react-redux's connect
HOC:
;
Queries can also be passed to resolver methods when they are called programmatically.
{ const query = title: eventtargetvalue ; thispropsposts; }
Adding mutation methods to resolvers
Now that we know how to pass queries into resolvers and how to access resolver methods programmatically, we can add some mutative methods to our postsResolver
. Let's add a method that creates new entries.
const postsResolver = ... { ; };
To use this method, in we would call it from a handler in the component:
{ const post = edata; thispropsposts; } { return <button onClick=thishandleSubmit>New Post</button> } resolver: postsResolver NewPost;
Updating remote data
Calling the create method in the previous example creates a new post on the server but we still need to display the post that the user created in the UI.
To update the local data store, return a promise that resolves with updated collection from the resolver's mutation method:
const postsResolver = ... { return ; }; { const post = edata; thispropsposts; } { return <button onClick=thishandleSubmit>New Post</button> } resolver: postsResolver NewPost;
In the above example we execute a second request to the API to fetch the updated resource. If however the response body of the POST request contains the complete updated collection of posts we could resolve the promise with that data instead, saving an extra round trip to the API:
const postsResolver = ... { return ; };
There are times when in order to form a complete update you'll need access to the data in the local store (say, for example, if your server responds with just the created resource and you need to add it to the existing collection).
When getting existing data it is important to ensure that your update is dispatched immediately after to avoid the data you retrieved going stale. This means you must pull the latest data out of the store and in the same tick of the event loop dispatch your update. To do this wrap your update (promise) in a thunk, which takes a dispatch
and getCurrentData
function. This approach allows react-quest to turn control of dispatching updates over to the resolver (and you, the developer), to guarantee that you only ever update the store with the latest data.
For more details on why this is necessary see https://github.com/djgrant/react-quest/pull/4.
const postsResolver = ... { return ; };
Performing optimistic updates
Suppose we want to immediately update the local data store, even before it has been created on the server? We can perform an optimistic update by, instead of returning a single promise from our mutation handler, returning an array of promises. Each promise represents an update task and the local data store is updated with the result of each promise as it resolves. As a fail safe mechanism, if any of the promises reject then all updates are reverted.
As a fail safe mechanism, if a promise rejects, any updates from promises that were resolved prior in the cycle will get reverted.
Let's start with a simple example for this technique:
const numberResolver = ... { // the first promise will resolve with the data we hope to add const optimisticUpdate = Promise; // the second promise will resolve with the actual data const serverUpdate = { // mock an IO operation ; }; // return both promises in an array return optimisticUpdate serverUpdate }
In this example the local store is first updated with number
and then 100ms later it is updated with 2
.
Returning to our posts example, we can update the local store first with the user input using a promise that immediately resolves (the optimistic update), and then with the real data from the server. To add just a little extra complexity to the example, let's also handle cases where the server update fails. In such an event, we'd need to revert the effect of the optimistic update and resolve the server update with the original posts collection.
const postsResolver = ... { const optimisticUpdate = Promise; const serverUpdate = ; return optimisticUpdate serverUpdate; };
Credits
react-quest was inspired by a few projects in particular:
- Relay, which introduced the idea of colocating data queries and components
- Apollo, whose React client proved the versatility of redux as a local cache
- react-jobs, which influenced the design of the quest higher order components