Skip to content

Instantly share code, notes, and snippets.

@jamesarosen
Last active June 8, 2023 16:45
Show Gist options
  • Save jamesarosen/d380b6a73da0d695d1f7df01b95d02f7 to your computer and use it in GitHub Desktop.
Save jamesarosen/d380b6a73da0d695d1f7df01b95d02f7 to your computer and use it in GitHub Desktop.
Helper Functions in the Redux Store

I have a function that generates image URLs. This function combines some relatively static global configuration with some dynamic data that changes on every invocation. I say "relatively static" because the configuration is loaded asynchronously during the application boot, but remains fixed after that.

Option One

export default async function imageUrl(imageId, { size = 'normal' }) {
  if (imageId == null) return null
  
  const constantsResponse = await fetch('/api/constants')
  const imagesRoot = constantsResponse.json().imagesRoot
  
  return `${imagesRoot}/${imageId}?size=${size}`
}
  • Pro: This has great cohesion -- everything required for generating image URLs is all expressed in one place.
  • Con: The async nature makes this hard to use in components.
  • Con: If GET /api/constants isn't cached in the browser, it's terrible for rendering performance.
  • Con: Testing image URL generation requires stubbing an HTTP API.

Option Two

An alternative would be to take the root as an argument:

export default function imageUrl(imageId, { size = 'normal', imagesRoot }) {
  if (imageId == null || imagesRoot == null) return null

  return `${imagesRoot}/${imageId}?size=${size}`
}
  • Pro: Fast
  • Pro: No async
  • Pro: Easy to test
  • Con: requires passing the imagesRoot around all over the application.
  • Con: May end up with a performance problem if multiple components do the fetch('/api/constants') call and don't cache the result.

Option Three

We could have our JavaScript function reach out to some global state:

import store from 'my/redux/store'

export default function imageUrl(imageId, { size = 'normal' }) {
  if (imageId == null) return null
  
  const state = store.getState()
  const imagesRoot = state && state.constants && state.constants.imagesRoot
  if (imagesRoot == null) return null
  
  return `${imagesRoot}/${imageId}?size=${size}`
}
  • Pro: Fast
  • Pro: No async
  • Pro: Pretty easy to test by injecting state into the store
  • Con: Tied to Redux. In fact, it's tied to an instance of the Redux store being available at my/redux/store
  • Con: Temporal coupling. If you call the function before dispatching the fetchConstants action that populates state.constants.imagesRoot, you get null back.

Option Four

We can keep the function pure by using Currying:

export default function imageUrlForRoot(imagesRoot) {
  return function imageUrl(imageId, { size = 'normal' }) {
    if (imagesRoot == null || imageId == null) return null
    return `${imagesRoot}/${imageId}?size=${size}`
  }
}

Applications that don't use Redux can still use it:

imgTag.src = imageUrlForRoot('https://example.com/images')('cheese.png')

For applications that do use Redux, we can partially apply the function in a reducer:

// reducers/helpers.js
import imageUrlForRoot from 'lib/image-url'

const defaultState = { imageUrl: imageUrlForRoot(null) }

export function helpers(state = defaultState, action) {
  switch (action.type) {
    case 'RECEIVE_CONSTANTS':
      return {
        ...state,
        imageUrl: imageUrlForRoot(action.payload.imagesRoot),
      }
    default:
      return state
  }
}

And consume it in a component:

// Image.jsx
import { useSelector } from 'react-redux'

export default function Image({ imageId, alt, size }) {
  const imageUrl = useSelector('helpers.imageUrl')
  const src = imageUrl(imageId, { size })
  return <img alt={alt} src={src} />
}
  • Pro: Fast
  • Pro: No async
  • Pro: Very easy to test
  • Pro: Usable with or without Redux
  • Con: Requires the JS function plus an action-creator and a reducer
  • Con: Can't look at the import statements at the top of the file to find function dependencies
  • Con: Not a known pattern.
  • Con: Temporal coupling in the Redux version. If some root component doesn't dispatch an action that causes RECEIVE_CONSTANTS, all uses of imageUrl will return null.

Have you done this? Do you like the idea of putting partially-applied functions into the Redux store? Do you have a better alternative?

@jamesarosen
Copy link
Author

What values can imagesRoot assume? There is value that is most common among the use cases?

I'm imagining things like

  • in development: '/'
  • in staging: 'https://staging.example.com'
  • in production: 'https://images.example.com'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment