├── .babelrc
├── .eslintrc
├── .gitignore
├── .size-snapshot.json
├── .travis.yml
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── prettier.config.js
├── rollup.config.js
├── src
├── client.js
├── constants.js
├── context.js
├── functions.js
├── hooks.js
├── index.js
├── query.js
├── render.js
├── serializer.js
└── tests
│ ├── coerceValue.test.js
│ ├── getTypeMap.test.js
│ ├── parseQueryArg.test.js
│ ├── parseSchema.test.js
│ ├── schema.js
│ └── serializer.test.js
└── types
└── index.d.ts
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/env",
5 | {
6 | "modules": false,
7 | "exclude": [
8 | "@babel/plugin-transform-regenerator"
9 | ]
10 | }
11 | ],
12 | "@babel/react"
13 | ],
14 | "plugins": [
15 | [
16 | "module:fast-async",
17 | {
18 | "compiler": {
19 | "noRuntime": true
20 | }
21 | }
22 | ]
23 | ],
24 | "env": {
25 | "test": {
26 | "presets": [
27 | [
28 | "@babel/env",
29 | {
30 | "modules": "commonjs",
31 | "exclude": [
32 | "@babel/plugin-transform-regenerator"
33 | ]
34 | }
35 | ]
36 | ]
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": ["react-app", "prettier"],
4 | "env": {
5 | "es6": true
6 | },
7 | "parserOptions": {
8 | "sourceType": "module"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | node_modules
3 |
4 | # builds
5 | dist
6 |
7 | # misc
8 | .DS_Store
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | .history
13 |
--------------------------------------------------------------------------------
/.size-snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "dist/index.js": {
3 | "bundled": 39194,
4 | "minified": 20347,
5 | "gzipped": 6123
6 | },
7 | "dist/index.es.js": {
8 | "bundled": 38745,
9 | "minified": 19965,
10 | "gzipped": 6040,
11 | "treeshaked": {
12 | "rollup": {
13 | "code": 75,
14 | "import_statements": 57
15 | },
16 | "webpack": {
17 | "code": 1135
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 10
4 | script:
5 | - npm run test
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Ari Bouius
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # jsonapi-react
2 | A minimal [JSON:API](https://jsonapi.org/) client and [React](https://reactjs.org/) hooks for fetching, updating, and caching remote data.
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | ## Features
12 | - Declarative API queries and mutations
13 | - JSON:API schema serialization + normalization
14 | - Query caching + garbage collection
15 | - Automatic refetching (stale-while-revalidate)
16 | - SSR support
17 |
18 | ## Purpose
19 | In short, to provide a similar client experience to using `React` + [GraphQL](https://graphql.org/).
20 |
21 | 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](https://jsonapi.org/implementations/) that provide good `JSON:API` implementations,
22 | but none offer a seamless integration with `React` without incorporating additional libraries and/or model abstractions.
23 |
24 | Libraries like [react-query](https://github.com/tannerlinsley/react-query) and [SWR](https://github.com/zeit/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](https://github.com/yury-dymov/json-api-normalizer). But both require a non-trivial amount of cache invalidation configuration, given resources can be returned from any number of endpoints.
25 |
26 |
27 | ## Support
28 | - React 16.8 or later
29 | - Browsers [`> 1%, not dead`](https://browserl.ist/?q=%3E+1%25%2C+not+dead)
30 | - Consider polyfilling:
31 | - [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise)
32 | - [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API)
33 |
34 | ## Documentation
35 | - [Installation](#installation)
36 | - [Getting Started](#getting-started)
37 | - [Queries](#queries)
38 | - [Mutations](#mutations)
39 | - [Deleting](#deleting-resources)
40 | - [Caching](#caching)
41 | - [Manual Requests](#manual-requests)
42 | - [Server-Side Rendering](#server-side-rendering)
43 | - [API Reference](#api)
44 | - [useQuery](#useQuery)
45 | - [useMutation](#useMutation)
46 | - [useIsFetching](#useClient)
47 | - [useClient](#useClient)
48 | - [ApiClient](#ApiClient)
49 | - [ApiProvider](#ApiProvider)
50 | - [renderWithData](#renderWithData)
51 | ## Installation
52 | ```
53 | npm i --save jsonapi-react
54 | ```
55 |
56 | ## Getting Started
57 | To begin you'll need to create an [ApiClient](#ApiClient) instance and wrap your app with a provider.
58 | ```javascript
59 | import { ApiClient, ApiProvider } from 'jsonapi-react'
60 | import schema from './schema'
61 |
62 | const client = new ApiClient({
63 | url: 'https://my-api.com',
64 | schema,
65 | })
66 |
67 | const Root = (
68 |
69 |
70 |
71 | )
72 |
73 | ReactDOM.render(
74 | Root,
75 | document.getElementById('root')
76 | )
77 | ```
78 |
79 | ### Schema Definition
80 | 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.
81 |
82 | ```javascript
83 | new ApiClient({
84 | schema: {
85 | todos: {
86 | type: 'todos',
87 | relationships: {
88 | user: {
89 | type: 'users',
90 | }
91 | }
92 | },
93 | users: {
94 | type: 'users',
95 | relationships: {
96 | todos: {
97 | type: 'todos',
98 | }
99 | }
100 | }
101 | }
102 | })
103 | ```
104 |
105 | You can also describe and customize how fields get deserialized. Field configuration is entirely _additive_, so any omitted fields are simply passed through unchanged.
106 | ```javascript
107 | const schema = {
108 | todos: {
109 | type: 'todos',
110 | fields: {
111 | title: 'string', // shorthand
112 | status: {
113 | resolve: status => {
114 | return status.toUpperCase()
115 | },
116 | },
117 | created: {
118 | type: 'date', // converts value to a Date object
119 | readOnly: true // removes field for mutations
120 | }
121 | },
122 | relationships: {
123 | user: {
124 | type: 'users',
125 | }
126 | }
127 | },
128 | }
129 | ```
130 |
131 | ## Queries
132 | To make a query, call the [useQuery](#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.
133 | ```javascript
134 | import { useQuery } from 'jsonapi-react'
135 |
136 | function Todos() {
137 | const { data, meta, error, isLoading, isFetching } = useQuery('todos')
138 |
139 | return (
140 |
141 | isLoading ? (
142 |
...loading
143 | ) : (
144 | data.map(todo => (
145 |
{todo.title}
146 | ))
147 | )
148 |
149 | )
150 | }
151 | ```
152 |
153 | The argument simply gets converted to an API endpoint string, so the above is equivalent to doing
154 | ```javascript
155 | useQuery('/todos')
156 | ```
157 |
158 | As syntactic sugar, you can also pass an array of URL segments.
159 | ```javascript
160 | useQuery(['todos', 1])
161 | useQuery(['todos', 1, 'comments'])
162 | ```
163 |
164 | 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](https://github.com/ljharb/qs).
165 | ```javascript
166 | useQuery(['todos', {
167 | filter: {
168 | complete: 0,
169 | },
170 | include: [
171 | 'comments',
172 | ],
173 | page: {
174 | number: 1,
175 | size: 20,
176 | },
177 | }])
178 | ```
179 |
180 | If a query isn't ready to be requested yet, pass a _falsey_ value to defer execution.
181 | ```javascript
182 | const id = null
183 | const { data: todos } = useQuery(id && ['users', id, 'todos'])
184 | ```
185 |
186 | ### Normalization
187 | The API response data gets automatically deserialized into a nested resource structure, meaning this...
188 | ```javascript
189 | {
190 | "data": {
191 | "id": "1",
192 | "type": "todos",
193 | "attributes": {
194 | "title": "Clean the kitchen!"
195 | },
196 | "relationships": {
197 | "user": {
198 | "data": {
199 | "type": "users",
200 | "id": "2"
201 | }
202 | },
203 | },
204 | },
205 | "included": [
206 | {
207 | "id": 2,
208 | "type": "users",
209 | "attributes": {
210 | "name": "Steve"
211 | }
212 | }
213 | ],
214 | }
215 | ```
216 |
217 | Gets normalized to...
218 | ```javascript
219 | {
220 | id: "1",
221 | title: "Clean the kitchen!",
222 | user: {
223 | id: "2",
224 | name: "Steve"
225 | }
226 | }
227 | ```
228 |
229 | ## Mutations
230 | To run a mutation, first call the [useMutation](#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.
231 | ```javascript
232 | import { useMutation } from 'jsonapi-react'
233 |
234 | function AddTodo() {
235 | const [title, setTitle] = useState('')
236 | const [addTodo, { isLoading, data, error, errors }] = useMutation('todos')
237 |
238 | const handleSubmit = async e => {
239 | e.preventDefault()
240 | const result = await addTodo({ title })
241 | }
242 |
243 | return (
244 |
252 | )
253 | }
254 | ```
255 |
256 | ### Serialization
257 | The mutation function expects a [normalized](#normalization) resource object, and automatically handles serializing it. For example, this...
258 | ```javascript
259 | {
260 | id: "1",
261 | title: "Clean the kitchen!",
262 | user: {
263 | id: "1",
264 | name: "Steve",
265 | }
266 | }
267 | ```
268 |
269 | Gets serialized to...
270 | ```javascript
271 | {
272 | "data": {
273 | "id": "1",
274 | "type": "todos",
275 | "attributes": {
276 | "title": "Clean the kitchen!"
277 | },
278 | "relationships": {
279 | "user": {
280 | "data": {
281 | "type": "users",
282 | "id": "1"
283 | }
284 | }
285 | }
286 | }
287 | }
288 | ```
289 |
290 | ## Deleting Resources
291 | `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](#manual-requests) on the `client` instance.
292 |
293 |
294 | ## Caching
295 | `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.
296 |
297 | ### Configuration
298 | Caching behavior is determined by two configuration values:
299 | - `cacheTime` - The number of seconds the response should be cached from the time it is received.
300 | - `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.
301 |
302 | To assign default caching rules for the whole application, configure the client instance.
303 | ```javascript
304 | const client = new ApiClient({
305 | cacheTime: 5 * 60,
306 | staleTime: 60,
307 | })
308 | ```
309 |
310 | To override the global caching rules, pass a configuration object to `useQuery`.
311 | ```javascript
312 | useQuery('todos', {
313 | cacheTime: 5 * 60,
314 | staleTime: 60,
315 | })
316 | ```
317 |
318 | ### Invalidation
319 | When performing mutations, there's a good chance one or more cached queries should get invalidated, and potentially refetched immediately.
320 |
321 | Since the JSON:API schema allows us to determine which resources (including relationships) were updated, the following steps are automatically taken after successful mutations:
322 |
323 | - Any cached results that contain resources with a `type` that matches either the mutated resource, or its included relationships, are invalidated and refetched for active query instances.
324 | - If a query for the mutated resource is cached, and the query URL matches the mutation URL (i.e. the responses can be assumed analogous), the cache is updated with the mutation result and delivered to active instances. If the URL's don't match (e.g. one used refinements), then the cache is invalidated and the query refetched for active instances.
325 |
326 | To override which resource types get invalidated as part of a mutation, the `useMutation` hook accepts a `invalidate` option.
327 | ```JavaScript
328 | const [mutation] = useMutation(['todos', 1], {
329 | invalidate: ['todos', 'comments']
330 | })
331 | ```
332 |
333 | To prevent any invalidation from taking place, pass false to the `invalidate` option.
334 | ```JavaScript
335 | const [mutation] = useMutation(['todos', 1], {
336 | invalidate: false
337 | })
338 | ```
339 |
340 | ## Manual Requests
341 | Manual API requests can be performed through the client instance, which can be obtained with the [useClient](#useClient) hook
342 |
343 | ```javascript
344 | import { useClient } from 'jsonapi-react'
345 |
346 | function Todos() {
347 | const client = useClient()
348 | }
349 | ```
350 |
351 | The client instance is also included in the object returned from the `useQuery` and `useMutation` hooks.
352 | ```javascript
353 | function Todos() {
354 | const { client } = useQuery('todos')
355 | }
356 |
357 | function EditTodo() {
358 | const [mutate, { client }] = useMutation('todos')
359 | }
360 | ```
361 | The client request methods have a similar signature as the hooks, and return the same response structure.
362 |
363 | ```javascript
364 | # Queries
365 | const { data, error } = await client.fetch(['todos', 1])
366 |
367 | # Mutations
368 | const { data, error, errors } = await client.mutate(['todos', 1], { title: 'New Title' })
369 |
370 | # Deletions
371 | const { error } = await client.delete(['todos', 1])
372 | ```
373 |
374 | ## Server-Side Rendering
375 | Full SSR support is included out of the box, and requires a small amount of extra configuration on the server.
376 |
377 | ```javascript
378 | import { ApiProvider, ApiClient, renderWithData } from 'jsonapi-react'
379 |
380 | const app = new Express()
381 |
382 | app.use(async (req, res) => {
383 | const client = new ApiClient({
384 | ssrMode: true,
385 | url: 'https://my-api.com',
386 | schema,
387 | })
388 |
389 | const Root = (
390 |
391 |
392 |
393 | )
394 |
395 | const [content, initialState] = await renderWithData(Root, client)
396 |
397 | const html =