React Fetching Hooks
⚠ This library is work-in-progress and published only for some real-life testing. Expect bugs, documentation inconsistency and breaking changes on every release until version 1.0.0.
Library for querying and mutating data for any backend, heavily inspired by Apollo.
- Simple and customizable (globally and per-request)
- Full SSR support: requests on server, works anywhere (not only Next.js)
- Caching with single shared cache: component's data can be updated by other query or mutation
- Errors are also cached
- Different fetch policies
- Integration with redux-devtools (work in progress)
- Network requests optimization and (some) race conditions handling
- Tree-shakable (ES6 modules)
- Written in typescript
Quick start
Add the library and error serializer:
yarn add react-fetching-hooks serialize-error
Create a client:
;; ; ;
Provide the client to your app via Provider
:
;; ;
Preload data and errors on server:
;;;;;;
Render the app in browser:
;;;; ReactDOM.hydrateReact.createElementApp, , document.getElementById'root';
Query and mutate data in your components:
;; // Component props // Data from API // Concrete request (query); // Concrete request (mutation); ;
In-depth
Queries and mutations
Queries and mutations use the same RequestData
objects.
Rule of thumb: if a request doesn't change any backend state by the fact of its execution, it's a query. Otherwise, it's a mutation.
I.e. request, returning current time, is a query, even though it returns new data every time.
RequestData
Requests are represented as RequestData
objects. Due to flexibility, RequestData
isn't a class, but rather a type defining a specific shape.
RequestData
is a valid native fetch RequestInit
object, excluding the body
field. In RequestData
, it can also be unserialized object
, which should be converted to RequestInit
-compatible string
via getRequestInit
function.
RequestData
also has additional fields:
fetchPolicy
- string, defines cache and network usage.root
- string, backend host, likehttps://my-app.com
. Must be absolute on server, may be relative on client. Will be processed bygetUrl
function.path
- string, like/data/:id
. Will be processed bygetUrl
function.pathParams
- object of params in path, like{id: "1"}
. Will be processed bygetUrl
function.queryParams
- object of params in query. Will be processed bygetUrl
function.body
- body of the request.headers
- headers of the request.lazy
- boolean, lazy requests are not performed automatically.refetchQueries
- array ofPartialRequestData
objects that will be queried after successful mutation with this request.optimisticResponse
- optimistic value fordata
field.disableSsr
- boolean, iftrue
, there will be no network request on server.disableInitialRenderDataRefetchOptimization
- boolean, iftrue
, the query with this request may refetch itself on initial render even with cached data, depending onfetchPolicy
.disableLoadingQueriesRefetchOptimization
- boolean, iftrue
, the mutation with this request will not cause loading queries to refetch.getUrl
- function for generating request's URL.getRequestInit
- function for generating request'sRequestInit
object (forfetch
). Can be used to stringify body and add appropriate headers.getId
- function for generating request's id. Requests with the same id are considered the same.processResponse
- function that returns data or throws an error based on request'sResponse
(native).merge
- function that mergesGeneralRequestData
andPartialRequestData
intoRequestData
.toCache
,fromCache
- functions for writing data to and reading data from cache.clearCacheFromOptimisticResponse
- function that removes optimistic response from cache.
Every field may be configured either globally on Client
instance or on concrete request.
RequestData
consists of GeneralRequestData
and PartialRequestData
.
GeneralRequestData
is defined onClient
instance asgeneralRequestData
. Essentially it contains default values for every request. You can define anyRequestData
fields exceptpath
.PartialRequestData
represents concrete request. AllRequestData
fields are optional exceptpath
(and possiblypathParams
,queryParams
,body
andheaders
, depending on types).
RequestData as generic
RequestData
(as well as GeneralRequestData
and PartialRequestData
) is actually a generic:
RequestData<SharedData, ResponseData, Error, PathParams, QueryParams, Body, Headers>
, where
SharedData
- same for every request, type of shared cache.ResponseData
- type of successful response's data (returned byprocessResponse
function).Error
- type of error, thrown byprocessResponse
.PathParams
,QueryParams
,Body
,Headers
- types of path params, query params, body and headers respectively.
Request functions
All functions are replaceable. The library provides default/example ones:
getIdUrl
- returns URL as id.getUrlDefault
- returns URL, usingquery-string
andpath-to-regexp
packages. If you don't want to include them, you can provide your own function and they will be tree-shaked.mergeShallow
- mergesGeneralRequestData
andPartialRequestData
objects shallowly.processResponseRestfulJson
- assumes that 2xx response is successful (and returns JSON), throwsResponseError
on non-2xx response.
toCache
and fromCache
functions are completely up to you. Here are some rules:
- Think in redux way.
toCache
should work like reducer.fromCache
should return the same object every time. - They should never throw.
- Return
undefined
, if there is no data in cache.
Optimistic responses
The value of optimisticResponse
field is used for data
field when the request starts.
When the request either fails or completes, clearCacheFromOptimisticResponse
function is used to clear cache before writing actual result (data or error).
In case of query, the function is optional. If it's not provided, the following will happen:
- on success, the cache won't be cleared before writing actual data,
- on fail, the cache will be cleared by calling
toCache
with previousdata
value.
In case of mutation, the function is required. Mutations are not cached to requestStates
, so there is no previous data
value available.
Cache
Cache is responsible for storing all cacheable data.
Cache consists of two parts: requestStates
and sharedData
.
requestStates
stores states of every individual request by its id. Request state consists of:
data
- result of successful request fromprocessResponse
function.loading
- boolean.loadingRequesterIds
- array of ids of requesters (callers, hooks) that executed the request.error
- error thrown fromprocessResponse
function.
requestStates
is for internal usage only.
sharedData
stores data from processResponse
function, normalized by toCache
function (from all requests). Shape of sharedData
is completely up to you, but generally it should be as normalized and deduplicated as possible.
If request result updates sharedData
, data
field in requestStates
is set to undefined
to prevent duplication.
Cache options:
serializeError
- function that converts error into serializable error object,deserializeError
- function that converts serializable error object into error,initialSerializableState
- object that will be used for cache initialization (bothrequestStates
andsharedData
). You can provide SSR result here. Defaults to empty cache.enableDevTools
- enable redux-devtools bindings. Defaults tofalse
.enableDataDuplication
- iftrue
,data
field inrequestStates
will always be set. Defaults tofalse
.
Queries are always cached in requestStates
. If toCache
is provided, the query will also be cached to sharedData
.
Mutations are never cached to requestStates
. If toCache
is provided, the mutation will be cached to sharedData
.
Client
Client is responsible for performing requests.
Cache must be accessed via client with the following functions:
extract()
- returns serializable cache object.purge(serializableCacheState?: SerializableCacheState)
- clears cache and aborts all loading queries/mutations. Use it to reset cache after e.g. logout.
On retrieving data from cache fromCache
function is prioritized over data
field from request state from requestStates
.
You must provide new Client
instance for every request in case of SSR.
Due to tree-shakeable design, you have to specify all parts of generalRequestData
manually.
Fetch policies
Queries:
cache-only
- never makes network request. Returns data from cache orundefined
.cache-first
- only makes network request, if there is no data in cache. Returns data from cache orundefined
and then data/error from network, if there was network request.cache-and-network
- always makes network request. Returns data orundefined
from cache and data/error from network.no-cache
- always makes network request. Returns data orundefined
from cache and data/error from network. It's cached on per-caller basis and never touchessharedData
, so initially it always returnsundefined
.
Mutations:
cache-only
,cache-first
,cache-and-network
- returns data/error and updatessharedData
no-cache
- returns data/error without touchingsharedData
Render details
Queries that are about to fetch data from network always start in loading state. That allows simple loading indicator for multiple related queries: const loading = firstQueryLoading || secondQueryLoading;
.
Queries with cached errors always start in loading state.
On initial render queries are not refetched if there is cached data (e.g. query with cached data and fetchPolicy: 'cache-and-network'
will initially render in non-loading state).
useQuery
You should build your own useQuery (using useQuery from the library) with API that is convenient for your app.
;
Options:
getPartialRequestId
- function that calculates id of passedPartialRequestData
object. It's optional with fallback to request'sgetId
, but that's unreliable. In real world you should provide it (i.e. defaultgetIdBase64
).hookId
- if you're using code-splitting, you must provide SSR- and code-splitting-friendly id. You'll likely use react-uid for that. Otherwise, you may omit this parameter.
Return values:
data
- query data (actual or previous). May appear as (and never switches to)undefined
(which means absence of data).loading
- boolean, indicating network request.loadingRequesterIds
- array of ids of requesters (callers, hooks) that executed the request.requesterId
- requester id of the hook. The value ofhookId
option, if provided. Otherwise, it's generated internally.error
- query error. Always swicthes toundefined
after network request success.abort
- function for aborting query.refetch
- function for forcing network request.
Loading state:
The loading
return value corresponds to the request itself. I.e. if hook A and hook B use the same request, and hook A initiates network request, hook B will receive loading: true
as well. In some cases that's undesirable.
You can get hook-specific loading state like this:
; ;
Abort:
abort()
without arguments won't abort request, if there are any other callers.abort(true)
will force abort for all callers.
Refetch:
refetch()
without arguments won't start new network request if there is one already.refetch(true)
will always start new network request.
If some query is loading, and another caller (hook) executes the same query (using RequestData
with the same id), this query is seamlessly merged with the first one, as if they were started simultaneously. This two queries will result in single network request.
useMutation
You should build your own useMutation (using useMutation from the library) with API that is convenient for your app.
;
mutate
- function that performs mutation with given request.
Known issues
- You can't return
undefined
as query data (and probably shouldn't, because empty data can be represented asnull
) - Race conditions handling is not 100% reliable, though no idea how to make it so (is it possible?).
- Redux-devtools integration is limited.
- It's probably better to move request functions to separate package.
- Tests are really poor.
- Errors in external code may lead to different errors in state and promise rejections (i.e.
query().catch()
). It's likely not going to be fixed, but you should get diverged error warnings in development.