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.
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.
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.
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 populatesstate.constants.imagesRoot
, you getnull
back.
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 ofimageUrl
will returnnull
.
Have you done this? Do you like the idea of putting partially-applied functions into the Redux store? Do you have a better alternative?
I'm imagining things like
'/'
'https://staging.example.com'
'https://images.example.com'