A minimal JSON:API client and React hooks for fetching, updating, and caching remote data.
A minimal JSON:API client and React hooks for fetching, updating, and caching remote data.
In short, to provide a similar client experience to using React
+ GraphQL.
The JSON:API
specification offers numerous benefits for writing and consuming REST API's, but at the expense of clients being required to manage complex schema serializations. There are several projects that provide good JSON:API
implementations,
but none offer a seamless integration with React
without incorporating additional libraries and/or model abstractions.
Libraries like react-query and SWR (both of which are fantastic, and obvious inspirations for this project) go a far way in bridging the gap when coupled with a serialization library like json-api-normalizer. But both require a non-trivial amount of cache invalidation configuration, given resources can be returned from any number of endpoints.
> 1%, not dead
npm i --save jsonapi-react
To begin you'll need to create an ApiClient instance and wrap your app with a provider.
import { ApiClient, ApiProvider } from 'jsonapi-react'
import schema from './schema'
const client = new ApiClient({
url: 'https://my-api.com',
schema,
})
const Root = (
<ApiProvider client={client}>
<App />
</ApiProvider>
)
ReactDOM.render(
Root,
document.getElementById('root')
)
In order to accurately serialize mutations and track which resource types are associated with each request, the ApiClient
class requires a schema object that describes your API's resources and their relationships.
new ApiClient({
schema: {
todos: {
type: 'todos',
relationships: {
user: {
type: 'users',
}
}
},
users: {
type: 'users',
relationships: {
todos: {
type: 'todos',
}
}
}
}
})
You can also describe and customize how fields get deserialized. Field configuration is entirely additive, so any omitted fields are simply passed through unchanged.
const schema = {
todos: {
type: 'todos',
fields: {
title: 'string', // shorthand
status: {
resolve: status => {
return status.toUpperCase()
},
},
created: {
type: 'date', // converts value to a Date object
readOnly: true // removes field for mutations
}
},
relationships: {
user: {
type: 'users',
}
}
},
}
To make a query, call the useQuery hook with the type
of resource you are fetching. The returned object will contain the query result, as well as information relating to the request.
import { useQuery } from 'jsonapi-react'
function Todos() {
const { data, meta, error, isLoading, isFetching } = useQuery('todos')
return (
<div>
isLoading ? (
<div>...loading</div>
) : (
data.map(todo => (
<div key={todo.id}>{todo.title}</div>
))
)
</div>
)
}
The argument simply gets converted to an API endpoint string, so the above is equivalent to doing
useQuery('/todos')
As syntactic sugar, you can also pass an array of URL segments.
useQuery(['todos', 1])
useQuery(['todos', 1, 'comments'])
To apply refinements such as filtering, pagination, or included resources, pass an object of URL query parameters as the last value of the array. The object gets serialized to a JSON:API
compatible query string using qs.
useQuery(['todos', {
filter: {
complete: 0,
},
include: [
'comments',
],
page: {
number: 1,
size: 20,
},
}])
If a query isn't ready to be requested yet, pass a falsey value to defer execution.
const id = null
const { data: todos } = useQuery(id && ['users', id, 'todos'])
The API response data gets automatically deserialized into a nested resource structure, meaning this...
{
"data": {
"id": "1",
"type": "todos",
"attributes": {
"title": "Clean the kitchen!"
},
"relationships": {
"user": {
"data": {
"type": "users",
"id": "2"
}
},
},
},
"included": [
{
"id": 2,
"type": "users",
"attributes": {
"name": "Steve"
}
}
],
}
Gets normalized to...
{
id: "1",
title: "Clean the kitchen!",
user: {
id: "2",
name: "Steve"
}
}
To run a mutation, first call the useMutation hook with a query key. The return value is a tuple that includes a mutate
function, and an object with information related to the request. Then call the mutate
function to execute the mutation, passing it the data to be submitted.
import { useMutation } from 'jsonapi-react'
function AddTodo() {
const [title, setTitle] = useState('')
const [addTodo, { isLoading, data, error, errors }] = useMutation('todos')
const handleSubmit = async e => {
e.preventDefault()
const result = await addTodo({ title })
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
/>
<button type="submit">Create Todo</button>
</form>
)
}
The mutation function expects a normalized resource object, and automatically handles serializing it. For example, this...
{
id: "1",
title: "Clean the kitchen!",
user: {
id: "1",
name: "Steve",
}
}
Gets serialized to...
{
"data": {
"id": "1",
"type": "todos",
"attributes": {
"title": "Clean the kitchen!"
},
"relationships": {
"user": {
"data": {
"type": "users",
"id": "1"
}
}
}
}
}
jsonapi-react
doesn't currently provide a hook for deleting resources, because there's typically not much local state management associated with the action. Instead, deleting resources is supported through a manual request on the client
instance.
jsonapi-react
implements a stale-while-revalidate
in-memory caching strategy that ensures queries are deduped across the application and only executed when needed. Caching is disabled by default, but can be configured both globally, and/or per query instance.
Caching behavior is determined by two configuration values:
cacheTime
- The number of seconds the response should be cached from the time it is received.staleTime
- The number of seconds until the response becomes stale. If a cached query that has become stale is requested, the cached response is returned, and the query is refetched in the background. The refetched response is delivered to any active query instances, and re-cached for future requests.To assign default caching rules for the whole application, configure the client instance.
const client = new ApiClient({
cacheTime: 5 * 60,
staleTime: 60,
})
To override the global caching rules, pass a configuration object to useQuery
.
useQuery('todos', {
cacheTime: 5 * 60,
staleTime: 60,
})
When performing mutations, there's a good chance one or more cached queries should get invalidated, and potentially refetched immediately.
Since the JSON:API schema allows us to determine which resources (including relationships) were updated, the following steps are automatically taken after successful mutations:
type
that matches either the mutated resource, or its included relationships, are invalidated and refetched for active query instances.To override which resource types get invalidated as part of a mutation, the useMutation
hook accepts a invalidate
option.
const [mutation] = useMutation(['todos', 1], {
invalidate: ['todos', 'comments']
})
To prevent any invalidation from taking place, pass false to the invalidate
option.
const [mutation] = useMutation(['todos', 1], {
invalidate: false
})
Manual API requests can be performed through the client instance, which can be obtained with the useClient hook
import { useClient } from 'jsonapi-react'
function Todos() {
const client = useClient()
}
The client instance is also included in the object returned from the useQuery
and useMutation
hooks.
function Todos() {
const { client } = useQuery('todos')
}
function EditTodo() {
const [mutate, { client }] = useMutation('todos')
}
The client request methods have a similar signature as the hooks, and return the same response structure.
# Queries
const { data, error } = await client.fetch(['todos', 1])
# Mutations
const { data, error, errors } = await client.mutate(['todos', 1], { title: 'New Title' })
# Deletions
const { error } = await client.delete(['todos', 1])
Full SSR support is included out of the box, and requires a small amount of extra configuration on the server.
import { ApiProvider, ApiClient, renderWithData } from 'jsonapi-react'
const app = new Express()
app.use(async (req, res) => {
const client = new ApiClient({
ssrMode: true,
url: 'https://my-api.com',
schema,
})
const Root = (
<ApiProvider client={client}>
<App />
</ApiProvider>
)
const [content, initialState] = await renderWithData(Root, client)
const html = <Html content={content} state={initialState} />
res.status(200)
res.send(`<!doctype html>\n${ReactDOM.renderToStaticMarkup(html)}`)
res.end()
})
The above example assumes that the Html
component exposes the initialState
for client rehydration.
<script>
window.__APP_STATE__ = JSON.stringify(state)
</script>
On the client side you'll then need to hydrate the client instance.
const client = new ApiClient({
url: 'https://my-api.com',,
})
client.hydrate(
window.__APP_STATE__
)
To prevent specific queries from being fetched during SSR, the useQuery
hook accepts a ssr
option.
const result = useQuery('todos', { ssr: false })
useQuery
queryArg: String | [String, Int, Params: Object] | falsey
config: Object
cacheTime: Int | null
:
staleTime: Int | null
ssr: Boolean
false
to disable server-side rendering of query.client: ApiClient
data: Object | Array | undefined
meta: Object | undefined
meta
object returned from a successful request, if present.links: Object | undefined
links
object returned from a successful request, if present.error: Object | undefined
refetch: Function
setData: Function(Object | Array)
data
value.client: ApiClient
useMutation
queryArg: String | [String, Int, Params: Object]
config: Object
invalidate: String | Array
method: String
POST
when creating a resource, and PATCH
when updating.client: ApiClient
mutate: Function(Object | Array)
data: Object | Array | undefined
meta: Object | undefined
meta
object returned from a successful request, if present.links: Object | undefined
links
object returned from a successful request, if present.error: Object | undefined
errors: Array | undefined
isLoading: Boolean
client: ApiClient
useIsFetching
isFetching: Boolean
true
if any query in the application is fetching.useClient
client: ApiClient
ApiClient
url: String
mediaType: String
application/vnd.api+json
.cacheTime: Int | Null
:
0
.staleTime: Int | null
null
.headers: Object
ssrMode: Boolean
true
when running in a server environment.typeof window === 'undefined'
.formatError: Function(error)
formatErrors: Function(errors)
fetch: Function(url, options)
fetch
.fetchOptions: Object
fetch
.fetch(queryKey: String | [String, Int, Params: Object], [config]: Object)
mutate(queryKey: String | [String, Int, Params: Object], data: Object | Array, [config]: Object)
delete(queryKey: String | [String, Int, Params: Object], [config]: Object)
clearCache()
addHeader(key: String, value: String)
removeHeader(key: String)
isFetching()
true
if a query is being fetched by the client.subscribe(callback: Function)
hydrate(state: Array)
ApiProvider
client: ApiClient
renderWithData
element: Object
client: ApiClient
content: String
initialState: Array