Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Managing fetch actions

Managing fetch actions

If you are fetching data from a server, your app needs to manage that relationship. The redux manual demonstrates the need for at least three action: FETCH_REQUEST, FETCH_FAILURE, and FETCH_SUCCESS. The redux manual's reddit example shows a slightly different setup, omitting the FAILURE and renaming SUCCESS to RECEIVE_DATA.

Long story short, we need to expose the API data fetching lifecycle to our app.

Fetching lifycycle:

  1. begin fetching
  2. receive data or receive error (or bail when cancelled)
  3. end fetching

That's a total of 4 (or 5) actions that are required for each API interaction.

Note: We'll see later that it's useful to keep "end" separate from "data" and "error". Even though "end" is implied when we have an error or if we receive data, the "end" action should be dispatched in either case.

Example: fetching posts

Let's walk through the example of fetching posts. We're going to create a redux module.

A redux module is a collection of actions and reducers that relate to a specific feature. Essentially, it's a folder with a handful of files representing each facet of redux.

  • actions — action creators and simple thunks
  • constants — action types
  • reducers — functions for managing the state for this feature
  • sagas — functions for managing complex side-effects
  • selectors — functions for reading values from the state for this feature

We would want to create a similar structure for every API call. We're sketching our "posts" now but you could imagine sketching out a module for each entity type by replacing all instances of "posts" with "my-entity-name" as required.

modules/posts/
  actions/index.js
  constants/index.js
  reducers/index.js
  sagas/index.js
  selectors/index.js
  index.js

modules/posts/constants

The glue of any module is the constants. A constant is used in actions, reducers and sagas.

  • Keep constants in their own file, even though it's annoying
  • Give constants terse names; try to remove any namespace from the constant name
  • Namespace constant values to avoid collisions with other parts of the app
export const BEGIN_FETCHING_POSTS = '@@my-app/posts/BEGIN_FETCHING_POSTS'
export const CANCEL_FETCHING_POSTS = '@@my-app/posts/CANCEL_FETCHING_POSTS'
export const END_FETCHING_POSTS = '@@my-app/posts/END_FETCHING_POSTS'
export const ERROR_FETCHING_POSTS = '@@my-app/posts/ERROR_FETCHING_POSTS'
export const FETCH_POSTS = '@@my-app/posts/FETCH_POSTS'
export const RECEIVE_POSTS = '@@my-app/posts/RECEIVE_POSTS'

Question: Should we drop the _POSTS suffix? Probably not. Terseness is important but in this case "posts" is descriptive.

modules/posts/actions

Actions are one-half of the interface between our app and our redux store (the other half are selectors). Using redux-actions makes it dead simple to turn our constants into action creators.

  • Match the action name to the constant
  • Avoid creating actions as thunks
import { createAction } from 'redux-actions'

import {
  BEGIN_FETCHING_POSTS,
  CANCEL_FETCHING_POSTS,
  END_FETCHING_POSTS,
  ERROR_FETCHING_POSTS,
  RECEIVE_POSTS
} from '../constants'

export const beginFetchingPosts = createAction(BEGIN_FETCHING_POSTS)
export const cancelFetchingPosts = createAction(CANCEL_FETCHING_POSTS)
export const endFetchingPosts = createAction(END_FETCHING_POSTS)
export const errorFetchingPosts = createAction(END_FETCHING_POSTS)
export const receivePosts = createAction(RECEIVE_POSTS)

modules/posts/sagas

modules/posts/sagas/index.js

import { watchActions } from 'redux-saga-watch-actions'

import {
  FETCH_POSTS
} from '../constants'
import fetchPosts from './fetchPosts'

const rootSaga = watchActions({
  [FETCH_POSTS]: fetchPosts
})

export default rootSaga

modules/posts/sagas/fetchPosts.js

import { call, put, select } from 'redux-saga/effects'

import {
  beginFetchingPosts,
  cancelFetchingPosts,
  endFetchingPosts
  errorFetchingPosts
  receivePosts
} from '../actions'
import { selectAnyFetching, selectCancelledByQuery, selectFetchingByQuery } from '../selectors'

const fetchPosts = function*(action) {
  // TODO: bail on malformed query
  const query = action.payload

  // bail on already fetching (same query)
  const fetching = yield select(selectFetchingByQuery(query))
  if (fetching) { return }

  // cancel other fetching queries (any query)
  const fetchingAny = yield select(selectAnyFetching)
  if (fetchingAny) {
    yield put(cancelFetchingPosts())
  }

  // tell the app we're starting
  yield put(beginFetchingPosts(query))

  try {
    const response = yield call(fetch, 'https://www.reddit.com/r/reactjs.json')

    // bail on already cancelled
    const cancelled = yield select(selectCancelledByQuery(query))
    if (cancelled) { return }

    const data = yield call(response.json)
    yield receivePosts({ data, query })
  } catch (error) {
    yield errorFetchingPosts({ error, query })
  }

  // tell the app we're done
  yield put(endFetchingPosts(query))
}

export default fetchPosts

State

const state = {
  posts: {
    data: {
      'uuid-as-string': {
        id: 'uuid-as-string',
        type: 'posts',
        attributes: { title, description, content},
        relationships: {
          author: { data: { id: 'uuid-as-atring' type: 'person' }, meta: {} },
          comments: { data: [{ id: 'uuid-as-atring' type: 'comment' }], meta: {} },
        },
        meta: {
          cancelled: false,
          changedAttributes: { title },
          changedRelationships: {},
          deleted: false,
          deleting: false,
          fetchedAt: new Date(),
          fetching: false,
          savedAt: new Date(),
          saving: false
        }
      }
    },
    resultSets: {
      'query-as-string': {
        pages: {
          0: [{ id: 'uuid-as-atring' type: 'post' }]
        },
        meta: {
          cancelled: false,
          fetchedAt: new Date(),
          fetching: false,
          limit: 20,
          offset: 0
        }
      }
    }
  }
}

modules/posts/selectors

import { composeSelectors, createSelector, get, memoizeSelector, withOptions } from '@comfy/redux-selectors'

export const selectRoot = createSelector('posts')

export const selectData = composeSelectors(selectRoot, 'data')

export const selectPostById = withOptions(id => composeSelectors(
  selectData,
  id
))

export const selectResultSets = composeSelectors(selectRoot, 'resultSets')

export const selectResultSetsByKey = withOptions(key => composeSelectors(
  selectResultSets,
  key
))

export const selectAllResultsByQuery = withOptions(query => {
  const key = JSON.stringify(query)
  return composeSelectors(
    selectResultSetsByKey(key),
    'pages'
    memoizeSelector(pages => Object.keys(pages).reduce((all, index) => all.concat(pages[index]), []))
  )
})

export const selectResultsForQueryByOffset = withOptions((query, offset = 0) => {
  const key = JSON.stringify(query)
  return composeSelectors(
    selectResultSetsByKey(key),
    'pages',
    offset
  )
})

export const selectCancelledByQuery = withOptions(query => {
  const key = JSON.stringify(query)
  return composeSelectors(
    selectResultSets,
    key,
    'meta.cancelled'
  )
})

export const selectFetchingByQuery = withOptions(query => {
  const key = JSON.stringify(query)
  return composeSelectors(
    selectResultSets,
    key,
    'meta.fetching'
  )
})

export const selectAnyFetching = composeSelectors(
  selectResultSets,
  memoizeSelector(resultSets => Object.keys(resultSets).some(key => get(state, `['${key}'].meta.fetching`)))
)

Reducers

modules/posts/reducers

import { combineReducers } from 'redux'

import data from './dataReducer'
import resultSets from './resultSetsReducer'

const rootReducer = combineReducers({
  data,
  resultSets
})

export default rootReducer

modules/posts/reducers/dataReducer.js

import { handleActions } from 'redux-actions'
import reduceReducers from 'reduce-reducers'
import { get } from '@comfy/redux-selectors'

import {
  RECEIVE_POSTS,
  RECEIVE_POST,
  POST_TYPE
} from '../constants'
import {
  receivePost
} from '../actions'
import postReducer from './postReducer'

const initialState = {}

const validId = id => !!id

const selectPostId = action => {
  const { payload: post } = action
  const { id, type } = post
  if (type !== POST_TYPE || !validId(id)) { return undefined }
  return id
}

const keyReducerByActionSelector = (reducer, selector) => (state, action) => {
  const key = selector(action)
  if (key === undefined) { return state }
  return {
    ...state,
    [key]: reducer(get(state, key), action)
  }
}

const mapActionToPost = keyReducerByActionSelector(postReducer, selectPostId)

const dataReducer = reduceReducers(
  handleActions({
    [RECEIVE_POSTS]: (state, action) => {
      const { payload } = action
      const { data } = payload
      if (!data || !Array.isArray(data) || !data.length) { return state }
      return {
        ...data.reduce(
          (newState, post) => mapActionToPost(newState, receivePost(post)),
          state
        )
      }
    },
    [RECEIVE_POST]: receivePostReducer
  }, initialState),
  mapActionToPost
)

export default dataReducer

modules/posts/reducers/postReducer.js

import { handleActions } from 'redux-actions'
import reduceReducers from 'reduce-reducers'
import { get } from '@comfy/redux-selectors'

import {
  RECEIVE_POST,
  POST_TYPE
} from '../constants'
import attributesReducer from './attributesReducer'
import relationshipsReducer from './relationshipsReducer'
import postMetaReducer from './postMetaReducer'

const initialState = {}

const validId = id => !!id

const validPostAction = action => {
  const { payload: post } = action
  const { id, type } = post
  if (type !== POST_TYPE || !validId(id)) { return false }
  return true
}

const keyReducerByActionValidator = (key, reducer, validator) => (state, action) => {
  const valid = validator(action)
  if (valid) { return state }
  return {
    ...state,
    [key]: reducer(get(state, key), action)
  }
}

const postReducer = reduceReducers(
  handleActions({
    [RECEIVE_POST]: (state, action) => {
      const valid = validPostAction(action)
      if (!valid) { return undefined }
      const { payload: post } = action
      return {
        ...state,
        ...post,
        attributes: attributesReducer(get(state, 'attributes'), action),
        relationships: relationshipsReducer(get(state, 'relationships'), action)
        meta: postMetaReducer(get(state, 'meta'), action)
      }
    },
  }, initialState),
  keyReducerByActionValidator('attributes', attributesReducer, validPostAction),
  keyReducerByActionValidator('relationships', relationshipsReducer, validPostAction),
  keyReducerByActionValidator('meta', postMetaReducer, validPostAction)
)

export default postReducer

modules/posts/reducers/resultSetsReducer.js

import { handleActions } from 'redux-actions'
import reduceReducers from 'reduce-reducers'
import { get } from '@comfy/redux-selectors'

import {
  RECEIVE_POSTS,
  POST_TYPE
} from '../constants'
import resultSetReducer from './resultSetReducer'

const initialState = {}

const validId = id => !!id

const queryKeyCache = new WeakMap()

const selectQueryKey = action => {
  const { payload } = action
  const { query } = payload
  if (!query) { return undefined }
  if (!queryKeyCache.has(query)) {
    const key = JSON.stringify(query) // or Query.stringify
    queryKeyCache.set(query, key)
  }
  return queryKeyCache.get(query)
  return key
}

const keyReducerByActionSelector = (reducer, selector) => {
  let prevResult
  return (state, action) => {
    const key = selector(action)
    if (key === undefined) { return state }
    // only merge on new result
    const result = reducer(get(state, key), action)
    if (result === prevResult) { return state }
    prevResult = result
    return {
      ...state,
      [key]: result
    }
  }
}

const resultSetsReducer = keyReducerByActionSelector(resultSetReducer, selectQueryKey)

export default resultSetsReducer
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.