1.0.0 • Published 5 years ago

@zup-next/redux-resource v1.0.0

Weekly downloads
464
License
MIT
Repository
github
Last release
5 years ago

Redux Resource

After dealing multiple times with making network requests, setting their state and rendering stuff according to the state of the request and the data returned from the server, we observed that we always do basically the same thing. Instead of repeating the same logic over and over again, we built this library for internal usage at Zup IT. We use this in our projects built with React and Redux.

Inspired by REST apis, this library abstracts every request as part of a Resource, hence the name. A resource is an entity with any of the following operations: "load", "create", "update" and "remove".

A resource can be defined by simply calling createResource. See the code below:

import { createResource } from '@zup-it/redux-resource'

const profileApi = {
  load: () => axios.get('https://example.com/profile').then(response => response.data),
  update: (data) => axios.put('https://example.com/profile', data).then(response => response.data),
}

const profile = createResource('PROFILE', profileApi)

export default { profile }

profile in the code above is an object with the properties { types, actions, reducer, sagas }. You can register the generated reducer to the redux store and the sagas to the saga-middleware:

import resources from './resources'
import { createStore, applyMiddleware, combineReducers } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { createEffects } from '@zup-it/redux-resource'

const sagaMiddleware = createSagaMiddleware()

const store = createStore(
  combineReducers({ profile: resources.profile.reducer }),
  applyMiddleware(sagaMiddleware),
)

const sagas = function* run() {
  yield createEffects(resources.profile.sagas)
}

sagaMiddleware.run(sagas)

export default store

Given you correctly set up the store provider for your application. You can now use the resource in any react component connected by redux:

import react, { PureComponent } from 'react'
import resources from './resources'
import { isPristine, isLoading, hasLoadError } from '@zup-it/redux-resource'

class Profile extends PureComponent {
  
  componentDidMount() {
    const { loadProfile } = this.props
    loadProfile()
  }

  render() {
    const { profile } = this.props

    if (isPristine(profile)) return null
    if (isLoading(profile)) return <p>loading...</p>
    if (hasLoadError(profile)) return <p>error!</p>

    const { name, lastName, birthDate, age } = profile.data

    return (
      <h1>Profile</h1>
      <ul>
        <li>Name: {name}</li>
        <li>Last name: {lastName}</li>
        <li>Birth date: {birthDate}</li>
        <li>age: {age}</li>
      </ul>
    )
  }
}

const mapStateToProps = ({ profile }) => ({ profile })
const actions = { loadProfile: resources.profile.actions.load }

export default connect(mapStateToProps, actions)(Profile)

In the code above, the resource "profile" gets injected through the redux connect method. The load operation is dispatched through the action creator profile.actions.load and we check the status of the request by verifying its status through the helper functions isPristine (nothing happened to the load operation yet), isLoading, hasLoadError and hasLoadSuccess.

To deal with an update to the profile, for instance, we'd use profile.actions.update and we'd check the status through the functions isUpdating, hasUpdateError and hasUpdateSuccess.

By building our application around the concept resources, we were able to create a generic behavior for every request. With this, we eliminated the need for creating different reducers, types, actions and sagas, reducing, by a lot, the time needed to implement the functionalities we needed. Furthermore, we were able to provide a very simple model for declaring and using requests, which made it much easier to find and debug possible problems.

Installation

yarn add @zup-it/redux-resource

or

npm install @zup-next/redux-resource

Resources

Suppose we need to build a simplified application for selling digital movies. This application will display the user profile, balance, credit cards, a movie catalog and it will provide interfaces for ordering a movie, buying balance, managing credit cards and updating the profile. The list below describes all resources and their operations we'd need to create for this app:

  • Profile: load, update
  • Balance: load, update
  • Credit card: load, create, remove
  • Movie catalog: load
  • Order: create

These resources can be created using the following code:

import axios from 'axios'
import { createResource } from '@zup-it/redux-resource'

const api = axios.create({ baseURL: 'https://example.com' })

api.interceptors.response.use(response => response.data)

const profileApi = {
  load: () => api.get('/profile'),
  update: (data) => api.put('/profile', data),
}

const balanceApi = {
  load: () => axios.get('/balance'),
  update: (data) => axios.put('/balance', data),
}

const creditCardApi = {
  load: () => axios.get('/creditCard'),
  create: (data) => axios.post('/creditCard', data),
  remove: (id) => axios.delete('/creditCard', id),
}

const movieCatalogApi = {
  load: () => api.get('/movieCatalog'),
}

const orderApi = {
  create: () => api.post('/order'),
}

const profile = createResource('PROFILE', profileApi)
const balance = createResource('PROFILE', balanceApi)
const creditCard = createResource('CREDIT_CARD', creditCardApi)
const movieCatalog = createResource('MOVIE_CATALOG', movieCatalogApi)
const order = createResource('ORDER', orderApi)

export default {
  profile,
  balance,
  creditCard,
  movieCatalog,
  order,
}

State of a resource in Redux

Every resource is represented in the redux state by the following structure (Typescript notation):

{
  data: any,
  load: { status: Status, error: any | null },
  update: { status: Status, error: any | null },
  create: { status: Status, error: any | null },
  remove: { status: Status, error: any | null },
}

data corresponds to the values returned by the api. We generally use axios to make our api calls, but you can use whatever you want, just keep in mind that data will receive exactly what is returned from the api method. Axios, by default, returns the entire response object, with the http status, request details, payload, etc. We only want the payload. A tip when using axios is to use a response interceptor that strips everything, but the payload from the response. By doing this, you can write api = { load: () => axios.get(url) } instead of api = { load: () => axios.get(url).then(response => response.data) }.

Each load, update, create and remove corresponds to the state of the request used to load, update, create or remove, respectively. In this state, status can be "pristine" (nothing happened yet), "pending", "success" or "error". The attribute error will be null unless an error ocurred while performing the operation. Otherwise, error will have the exception thrown by the api. A constant named Status is exported by the lib if you need to use it.

Return value of the createResource method

Each resource created by the method createResource is an object with the properties types, actions, reducer, sagas.

resource.types

types is an object mapping operation name to action type. Example using the namespace 'PROFILE':

{
  LOAD: 'PROFILE/LOAD',
  LOAD_PENDING: 'PROFILE/LOAD_PENDING',
  LOAD_SUCCESS: 'PROFILE/LOAD_SUCCESS',
  LOAD_ERROR: 'PROFILE/LOAD_ERROR',
  RESET_LOAD_STATUS: 'PROFILE/RESET_LOAD_STATUS',

  CREATE: 'PROFILE/CREATE',
  CREATE_PENDING: 'PROFILE/CREATE_PENDING',
  CREATE_SUCCESS: 'PROFILE/CREATE_SUCCESS',
  CREATE_ERROR: 'PROFILE/CREATE_ERROR',
  RESET_CREATE_STATUS: 'PROFILE/RESET_CREATE_STATUS',

  UPDATE: 'PROFILE/UPDATE',
  UPDATE_PENDING: 'PROFILE/UPDATE_PENDING',
  UPDATE_SUCCESS: 'PROFILE/UPDATE_SUCCESS',
  UPDATE_ERROR: 'PROFILE/UPDATE_ERROR',
  RESET_UPDATE_STATUS: 'PROFILE/RESET_UPDATE_STATUS',

  REMOVE: 'PROFILE/REMOVE',
  REMOVE_PENDING: 'PROFILE/REMOVE_PENDING',
  REMOVE_SUCCESS: 'PROFILE/REMOVE_SUCCESS',
  REMOVE_ERROR: 'PROFILE/REMOVE_ERROR',
  RESET_REMOVE_STATUS: 'PROFILE/RESET_REMOVE_STATUS',
}

resource.actions

actions is an object of action creators: If we create a resource with the namespace 'PROFILE', its actions would be an object with the following functions: load, setLoadPending, setLoadSuccess, setLoadError, resetLoadStatus, create, setCreatePending, setCreateSuccess, setCreateError, resetCreateStatus, update, setUpdatePending, setUpdateSuccess, setUpdateError, resetUpdateStatus, remove, setRemovePending, setRemoveSuccess, setRemoveError, resetRemoveStatus.

Each of these functions returns an action object ready to be dispatched by redux. The functions load, create, update and remove can receive one parameter which is passed to the corresponding api method.

load, create, update and remove are used to start an operation on the resource. All action creators starting with set are used by the sagas which are automatically generated by the createResource method. You shouldn't use them unless you have a really good reason too (e.g. altering a default saga). The action creators starting with reset can be used to reset the status of an operation to pristine and remove any error information. resetLoadStatus also wipes the contents of data.

resource.reducer

reducer is the reducer function that must be provided to redux when creating the store. It receives the current state and an action, returning a new state.

To register the reducers to the store, you can use the following code when creating the store:

import resources from './resources'
import { createStore, applyMiddleware, combineReducers } from 'redux'

...

const reducer = combineReducers({
  profile: resources.profile.reducer,
  balance: resources.balance.reducer,
  creditCard: resources.creditCard.reducer,
  movieCatalog: resources.movieCatalog.reducer,
  order: resources.order.reducer,
})

...

const store = createStore(reducer, applyMiddleware(sagaMiddleware)) // sagas are explained in the next section

export default store

If you're using lodash, it would be shorter to write:

const reducer = combineReducers(mapValues(resources, 'reducer'))

resource.sagas

sagas is an object mapping action type to saga generator function. Although it's not needed for a simple usage of this library, if you want to know more about saga generator functions, we recommend reading the redux-saga documentation.

If we use createResource to create a resource with the namespace 'PROFILE', for instance, the sagas property of the resulting object would have the keys: PROFILE/LOAD, PROFILE/UPDATE, PROFILE/CREATE and PROFILE/LOAD. The value for each key would be the corresponding saga generator function.

These functions should be provided to the saga middleware when creating the store. See the example below:

import resources from './resources'
import { createStore, applyMiddleware, combineReducers } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { createEffects } from '@zup-it/redux-resource'

...

const rootSaga = function* run() {
  yield createEffects({
    ...resources.catalog.sagas,
    ...resources.order.sagas,
    ...resources.profile.sagas,
    ...resources.wallet.sagas,
  })
}

const store = createStore(reducer, applyMiddleware(sagaMiddleware))
sagaMiddleware.run(rootSaga)
export default store

In the code above we used createEffects which is an utility function provided by our lib that takes an object relating action types to sagas and creates a root saga function. For further details on this function, please read the section "Other utilities".

If you're using lodash, it would be shorter to write:

const rootSaga = function* run() {
  yield createEffects(getTypeToSagaMap(mapValues(resources, 'sagas')))
}

getTypeToSagaMap is also an utility function provided by our library. For more details on it, please read the section "Other utilities".

onSuccess: the third and optional parameter of createResource

The function createResource can be passed a third parameter, which is the onSuccess handlers. Sometimes it is necessary to perform further actions after an operation succeeds, in these cases, you can use the onSuccess parameter to complement a saga instead of completely rewriting it.

To use this feature, it is advised some basic knowledge of redux-saga. Check their documentation at https://github.com/redux-saga/redux-saga.

Let's take the example we used before: a simple store to sell digital movies. The user has a balance, which is shown all the time in the header of the page. After the user places an order with his/her balance, the value of the balance will have decreased, but it won't be reflected in our application, because we didn't update the resource "balance" yet.

Through the onSuccess handler we can say that after every successful order, the balance must be fetched again. See the example below:

import { createResource } from '@zup-it/redux-resource'
import { put } from 'redux-saga/effects'

...

const balance = createResource('PROFILE', balanceApi)

function* onOrderSuccess() {
  yield put(balance.actions.load())
}

const order = createResource('ORDER', orderApi, { create: onOrderSuccess })

Alternatively, instead of reloading the balance, you could have changed its value according to the value of the order:

function* onOrderSuccess({ requestData: order }) {
  const currentBalance = yield select(state => state.balance.data)
  const newValue = currentBalance.value - order.value
  yield put(balance.actions.setLoadSuccess({ ...currentBalance, value: newValue }))
}

An onSuccess handler will always receive as parameter an object containing the keys requestData and responseData. The value of requestData is the parameter passed when calling the actionCreator load, create, update or remove. The value of responseData is the return value of the api method.

Utilities for checking the operation status

You can use the following functions to test the status of an operation of a resource. Every function below receives a resource object or undefined and returns a boolean.

Function nameDescription
isLoadPristineVerifies if the the load operation has not been started yet. True if resource is undefined or resource.load.status is "pristine". false otherwise.
isPristinealias for isLoadPristine
isLoadingVerifies if the the load operation is pending. True if resource.load.status is "pending". false otherwise.
hasLoadSuccessVerifies if the the load operation has succeeded. True if resource.load.status is "success". false otherwise.
hasLoadErrorVerifies if the the load operation had an error. True if resource.load.status is "error". false otherwise.
isCreatePristineVerifies if the the create operation has not been started yet. True if resource is undefined or resource.create.status is "pristine". false otherwise.
isCreatingVerifies if the the create operation is pending. True if resource.create.status is "pending". false otherwise.
hasCreateSuccessVerifies if the the create operation has succeeded. True if resource.create.status is "success". false otherwise.
hasCreateErrorVerifies if the the create operation had an error. True if resource.create.status is "error". false otherwise.
isUpdatePristineVerifies if the the update operation has not been started yet. True if resource is undefined or resource.update.status is "pristine". false otherwise.
isUpdatingVerifies if the the update operation is pending. True if resource.update.status is "pending". false otherwise.
hasUpdateSuccessVerifies if the the update operation has succeeded. True if resource.update.status is "success". false otherwise.
hasUpdateErrorVerifies if the the update operation had an error. True if resource.update.status is "error". false otherwise.
isRemovePristineVerifies if the the remove operation has not been started yet. True if resource is undefined or resource.remove.status is "pristine". false otherwise.
isRemovingVerifies if the the remove operation is pending. True if resource.remove.status is "pending". false otherwise.
hasRemoveSuccessVerifies if the the remove operation has succeeded. True if resource.remove.status is "success". false otherwise.
hasRemoveErrorVerifies if the the remove operation ha an error. True if resource.remove.status is "error". false otherwise.

Other utilities

This library also provides four other utility methods: createEffects, getTypeToSagaMap, createReducer and createResourceInitialState.

createEffects(typeToSagaMap, [effect])

This function facilitates the creation of a root saga to pass to the redux-saga middleware. It receives an object relating each action type its corresponding saga and transforms it into a generator function that takes all the provided sagas with the effect passed as parameter. The default effect is takeEvery.

Example:

import { createEffects } from '@zup-it/redux-resource'
...

const rootSaga = function* run() {
  yield createEffects({
    'PROFILE/LOAD': loadProfileSaga,
    'PROFILE/UPDATE': updateProfileSaga,
    'PRODUCT/LOAD': loadProductSaga,
  })
}

getTypeToSagaMap(sagaTree)

Generally, when using resources, you'll end up with structure like the following: resources = { resource1, resource2, resource3, resource4 }. To register all sagas you'd have to write something like:

const rootSaga = function* run() {
  yield createEffects({
    ...resources.resource1.sagas,
    ...resources.resource2.sagas,
    ...resources.resource3.sagas,
    ...resources.resource4.sagas,
  })
}

If you have many sagas, it can become very repetitive to write all this. You could use lodash to map resources directly to sagas and then use getTypeToSagaMap to transform the resulting saga tree in a map that relates action type to saga generator function.

We call a saga tree, any object following the format (typescript):

interface SagaTree {
  [key: string]: (() => any) | SagaTree,
}

To write the root saga in a single line, we could combine lodash's mapValues and getTypeToSagaMap. See the example below:

const rootSaga = function* run() {
  yield createEffects(getTypeToSagaMap(mapValues(resources, 'sagas')))
}

createReducer(initialState, handlers)

This function has nothing to do with creating a resource. It is used internally by the library and we expose it because it's very helpful when creating any kind of reducer. It creates a reducer function from an object where each key is an action type and each value is a function. The functions receive (currentState, action) and returns the new state.

Say we want to create a reducer for a counter. We'd need three actions: "RESET", "INCREMENT" and "DECREMENT". The code below shows how we can create the reducer using createReducer.

const initial = { count: 0 }

const handlers = {
  RESET: state => ({ count: 0 }),
  INCREMENT: (({ count }), { amount }) => ({ count: count + (amount || 1) }),
  DECREMENT: (({ count }), { amount }) => ({ count: count + (amount || 1) }),
}

const reducer = createReducer(initialState, handlers)

createResourceInitialState()

This function is useful mainly for testing purposes. It creates a blank (pristine) state for a resource. It will always return the following object:

{
  data: null,
  load: { status: Status.pristine, error: null },
  create: { status: Status.pristine, error: null },
  update: { status: Status.pristine, error: null },
  remove: { status: Status.pristine, error: null },
}

Dynamic resources

For most cases using createResource will be enough and you should always prefer it to createDynamicResource. But, sometimes, the static nature of a common resource will prevent you from implementing some functionalities.

Suppose you want to create a lazily loaded list of movies. At first, you fetch the list of movies, but this list doesn't come with all the properties of the movies, just some basic information like id and title. By clicking an item in the list, it fetches all the properties of the movie clicked, expands itself and shows the information. If the common resource is used, when a movie is fetched, it replaces the data of the previous movie, making it impossible to display information about two movies at the same time.

Common resources are static, it means that they can't have instances. If a load operation is triggered, the content of the previous load is replaced. It's not possible to separately track different load, create, update remove operations, because, in fact, there can be only one of each.

Using the movies example, a movie is fetched through the url https://example.com/movie/{id}. The id is generated dynamically, at runtime, and we want to track each movie as a separate resource. We must be able to differentiate the data and the operations status of each movie. movie/1, for instance, could have its load status as "pending", while movie/2 has its load status as "success".

In this case, we say "movie" is a dynamic resource, and it must be created via the createDynamicResource method. See the example below:

import { createResource, createDynamicResource } from 

const movieListApi = {
  load: () => axios.get(`https://example.com/movies`).then(response => response.data)
}

const movieApi = {
  load: id => axios.get(`https://example.com/movie/${id}`).then(response => response.data)
}

const movieList = createResource('MOVIE_LIST', movieListApi)
const movie = createDynamicResource('MOVIE', movieApi)

export default { movieList, movie }

A dynamic resource works almost exactly like a common resource. The only differences are:

  • Every api method receives the id as its first parameter and a data object as second parameter;
  • Every action creator must receive an id as its first parameter. load, for instance must be passed an id.
  • The redux state is no longer a resource. Instead, it is an object where every key is an id and its value is a resource.

Inside a component, different movies can be loaded like the following:

import resources from './resources'

function MyComponent({ loadMovie }) {
  loadMovie('id001')
  loadMovie('id002')
}

export default connect(null, { loadMovie: resources.movie.load })(MyComponent)

You can check the operation status by checking the redux state:

import { isLoading } from '@zup-it/redux-resource'

function MyComponent({ movie }) {
  if (isLoading(movie['id001'])) return <p>Loading movie with id = "id001"</p>
  if (isLoading(movie['id002'])) return <p>Loading movie with id = "id002"</p>
}

const mapStateToProps = ({ movie }) => ({ movie })

export default connect(mapStateToProps)(MyComponent)

You can use the data of a movie by using the value of data inside your resource.id:

function MyComponent({ movie }) {
  <p>Description of movie id001: {movie['id001'].data.description}</p>
  <p>Description of movie id002: {movie['id002'].data.description}</p>
}

const mapStateToProps = ({ movie }) => ({ movie })

export default connect(mapStateToProps)(MyComponent)

Testing

The redux-resource library is well tested and you don't need to retest all of its functionalities. If you dispatch the correct action and link everything correctly, your redux state will change accordingly. Having said that, we still advise testing your APIs and configuration. To do so, we suggest using nock and redux-saga-tester. These two libraries can be used to test everything related to your resource, from the dispatch of an action to the api call and finally the state change. See the example below for testing the success and error case for the load operation of a resource called "catalog".

describe('Resource tests: catalog', () => {
  it('should load catalog successfully', async () => {
    const sagaTester = new SagaTester({
      reducers: mapValues(resources, 'reducer'),
    })

    const payload = [{ id: '1', title: 'Lord of the Rings' }, { id: '2', title: 'Avatar' }]
    nock(url).get('/catalog').reply(200, payload)

    sagaTester.start(rootSaga)
    sagaTester.dispatch(catalog.actions.load())

    await sagaTester.waitFor(catalog.types.LOAD_SUCCESS)
    expect(sagaTester.getState().catalog).toStrictEqual({
      ...createResourceInitialState(),
      data: payload,
      load: { status: Status.success, error: null },
    })
  })

  it('should yield error while loading catalog', async () => {
    const sagaTester = new SagaTester({
      reducers: mapValues(resources, 'reducer'),
    })

    const errorPayload = { message: 'error-message' }
    nock(url).get('/catalog').reply(500, errorPayload)

    sagaTester.start(rootSaga)
    sagaTester.dispatch(catalog.actions.load())

    await sagaTester.waitFor(catalog.types.LOAD_ERROR)
    expect(sagaTester.getState().catalog).toStrictEqual({
      ...createResourceInitialState(),
      load: { status: Status.error, error: new ApiError(500, errorPayload) },
    })
  })
})

The code above was written using Jest and it was taken from one of our demonstration projects. Click here to check it out.

Types

This library is written in Typescript. If you don't use it, it's fine, all the code is transpiled to common js. But, if you do use Typescript, you can take advantage of all the types we already defined!

Every function provided by the library had its types declared and you don't need to worry about it. Although, it is important to know the following types to correctly type your components:

  • Resource<any>: it is the type of a resource in the redux state. You can pass your data type inside the generics. Example: Resource<Movie>.
  • DynamicResource<any>: same as the previous, but it declares a dynamic resource instead.
  • Status: an enum containing any possible status of an operation: pristine, pending, success or error.

See an example below:

import { Resource } from '@zup-it/redux-resource'
import { Profile } from './my-data-types'

interface Props {
  loadProfile: () => void,
  profile: Resource<Profile>,
}

class MyComponent extends PureComponent<Props> {
  ...
}

const mapStateToProps = ({ profile }) => ({ profile })
const actions = { loadProfile: resources.profile.actions.load }

export default connect(mapStateToProps, actions)(MyComponent)

Demo projects

We have some simple projects to demonstrate how the library works. They are:

  • demo-simple: a simple react application in javascript, without any kind of typing. The application is a store for selling digital movies.
  • demo-dynamic-resource: a project that lists movies and fetches the details of a movie when it's clicked. It's written in javascript (without typing) and it's an example of how to use a dynamic resource.
  • demo-typescript: it's the same project presented in "demo-simple", but written in Typescript.