Skip to main content

Side Effects

Thanks to injectEffect(), it's easy to colocate side effects with the state they interact with. Query atoms and injectPromise() give you even more tools for managing side effects. But importantly:

Zedux doesn't place any restrictions on how you manage side effects - especially asynchronous flows. You can use promises, observables, sockets, generators, or any third-party library.

Zedux stores do not have middleware. Read more on that in the Redux comparison. There are many other ways to manage side effects that avoid hijacking control from the store like middleware does.

This walkthrough has covered many of these. There's only a little more to learn to master side effects in Zedux.

you will learn
  • How to "render-as-you-fetch" with atoms
  • How to see what caused an atom or selector evaluation
  • Some effect subscriber nuances

Render As You Fetch

Many of the best practices for managing side effects in React still apply with Zedux. If you've been following the React community, you've probably heard that useEffect is not a great pattern for most side effects - it's prone to race conditions and some of its quirks are unintuitive. Much of this applies for injectEffect() with one big difference:

Since atoms aren't tightly-coupled to components, their side effects are also not tied to any single component. This makes atoms a much better place to put your side effects. Despite having almost the exact same API, injectEffect() is a much more useful tool for managing side effects than useEffect().

When React suspense came out, the React team introduced us to a new pattern called "render-as-you-fetch". This boils down to putting side effects in event handlers instead of useEffect. That is boiling it pretty heavily, but here's an example:

const userAtom = atom('user', () => {
const idStore = injectStore(1)
const promiseRef = injectRef()

if (!promiseRef.current) {
promiseRef.current = fetch(`/users/${idStore.getState()}`)
.then(data => data.json())
}

const setId = (newId: number) => {
idStore.setState(newId)
promiseRef.current = fetch(`/users/${newId}`).then(data => data.json())
}

return api(idStore).setExports({ setId }).setPromise(promiseRef.current)
})

function User() {
const userData = useAtomValue(userAtom)
const { setId } = useAtomInstance(userAtom).exports
...
}

Rather than using injectEffect() to listen to a state update on idStore, we kick off the request immediately in the setId callback. The state update will cause the User component to rerender, and when it does it will suspend again.

But of course you know all about that since you didn't skip the suspense walkthrough 'cause who would do that.

tip

In general, side effects should be colocated with the state they manipulate. React's hooks and props facilitate this. In Zedux, you have injectors and exports.

Syncing State

A key piece of managing side effects well is having as few of them as possible. Here's a common situation you might find yourself in:

Ions are often used as selectors with better memoization capabilities. As such, they often transform state from one shape to another. You might be tempted to use injectEffect() for this:

const sortUsers = list => [...list].sort((a, b) => a.name.localeCompare(b.name))

const sortedUsersAtom = ion('sortedUsers', () => {
const usersList = injectAtomValue(usersAtom)
const store = injectStore(sortUsers(usersList))

injectEffect(() => {
store.setState(sortUsers(usersList))
}, [usersList])

return store
})

This works but has a few imperfections:

  • It sets the initial state twice! Once on initial evaluation and once the first time the effect runs.
  • The sort logic is outside the ion so it can be reused.
  • injectEffect() runs the callback one tick later than the atom evaluation. This can be annoying to account for in tests.
  • sortUsers runs every time this ion evaluates and discards the result every time except on the first evaluation. This can be fixed by using the function overload of injectStore(), but that requires creating the store manually. Bleh.

The fix is simple: Remove injectEffect()! Remember that you can call .setState() on local stores during atom evaluation without triggering another evaluation. Let's refactor:

const sortedUsersAtom = ion('sortedUsers', () => {
const usersList = injectAtomValue(usersAtom)
const store = injectStore()

store.setState([...usersList].sort((a, b) => a.name.localeCompare(b.name)))

return store
})

This fixes every single problem. Zedux is very specifically designed to work like this. Take advantage of it!

tip

While updating a locally injected store during evaluation is good, updating stores in other atom instances during evaluation is not recommended. This is because atom instances can be created during render, and React doesn't like it when components update while another component is rendering.

But now. This did introduce a new potential problem: What if this atom had other dependencies? This would spread and sort the users list every time this atom reevaluates due to any of those dependencies changing. Well. You won't believe this ... but Zedux actually has yet another superpower:

injectWhy()

This little beast returns an array of reasons that tell you why the current atom is evaluating 🤯. Yes. 🤯.

The list is empty on initial evaluation. After that, every reason in the list has a sourceId field that gives the unique id, if any, of the dependency that caused the update. You can use this to determine if the current evaluation is the initial evaluation or was caused by a specific dependency updating:

const sortedUsersAtom = ion('sortedUsers', () => {
const usersInstance = injectAtomInstance(usersAtom)
const usersList = injectAtomValue(usersInstance)
const store = injectStore()
const reasons = injectWhy()

// only set state if this is the initial evaluation or this evaluation was at
// least partially caused by usersInstance updating:
if (
!reasons.length ||
reasons.some(reason => reason.sourceId === usersInstance.id)
) {
store.setState([...usersList].sort((a, b) => a.name.localeCompare(b.name)))
}

return store
})

See injectWhy(). Also see ecosystem.why() which gives the same power to atom selectors.

Odd-Looking Actions

Zedux's store composition model works by making actions themselves composable objects called ActionChains. The ActionChain contains needed metadata for Zedux to perform its store composition magic. ActionChains consist of any number of "meta nodes" with the wrapped action being the last node in the chain.

We'll go over the specifics of Zedux's store composition model in the store composition guide. For now you just need to know that, depending on your store setup, you may see these ActionChain objects as the action property in your effects handlers. Zedux doesn't unwrap the action because sometimes you will need to observe the metadata the action is wrapped in.

If you don't care about the metadata, you can get the wrapped action with removeAllMeta.

import { removeAllMeta, when } from '@zedux/react'

store.subscribe((newState, oldState, actionChain) => {
const action = removeAllMeta(actionChain)
})

Observables

Zedux stores are a type of observable. They can be passed directly to RxJS's from() to create full-fledged observables of state:

const filterUsersAtom = atom('filterUsers', 'Joe')

const instance = ecosystem.getInstance(filterUsersAtom)
const state$ = from(instance.store)

const users$ = state$.pipe(
filter(state => state.length >= 3),
switchMap(state => fetchUsers({ filter: state }))
)

You'll often want the first emission to be the current state. For that, the following pattern can be helpful:

import { defer, from, merge, of } from 'rxjs'

export const getState$ = <T>(store: Store<T>) =>
defer(() => merge(of(store.getState()), from(store)))

There are many ways that the extreme flexibility of RxJS and Zedux work together beautifully. For example, any atom's value can be an observable:

import { atom } from '@zedux/react'
import { of } from 'rxjs'

const observableValue = atom('observableValue', of('hello, world'))

This can be useful for taking advantage of Zedux's caching abilities to share observable references.

You can also export an observable from any atom:

import { api, atom, injectMemo } from '@zedux/react'
import { from } from 'rxjs'

const usersStreamAtom = atom('usersStream', () => {
const users$ = injectMemo(() => from(streamUsers()), [])

return api().setExports({
users$,
})
})

Handling observable subscriptions is easy with injectEffect():

import { atom, injectAtomValue, injectEffect, injectStore } from '@zedux/react'

const userAtom = atom('user', () => {
const user$ = injectAtomValue(userStreamAtom)
const store = injectStore(null)

injectEffect(() => {
const subscription = user$.subscribe(val => store.setState(val))

return () => subscription.unsubscribe()
}, [user$])

return store
})

Action Streams

One of the superpowers of Zedux stores is that they can be consumed as streams of actions. Call store.actionStream() to get an "observable"-like object that you can subscribe to to receive notifications for every action dispatched to the store.

This observable-like object is compatible with RxJS's from():

import { from } from 'rxjs'
import { filter } from 'rxjs/operators'
import { atom, actionFactory } from '@zedux/react'

const updateRow = actionFactory<RowUpdateEvent>('updateRow')
const messageBusAtom = atom('messageBus', null)

function BigGrid() {
const busInstance = useAtomInstance(messageBusAtom)
const gridRef = useRef()

useEffect(() => {
const subscription = from(busInstance.store.actionStream())
.pipe(filter(action => action.type === updateRow.type))
.subscribe(action =>
gridRef.current?.updateRow(action.payload.rowId, action.payload)
)

return () => subscription.unsubscribe()
}, [busInstance]) // instances can be force-destroyed so pass them here

return <ThirdPartyGridComponent ref={gridRef} />
}

Recap

  • injectEffect() is a simple tool for running side effects on changes that trigger reevaluations. It's also good for managing cleanup. BUT you may not need it.
  • injectWhy() can help reduce side effects.
  • Stores can be consumed directly as streams of state or as streams of actions via store.actionStream().
  • Zedux can handle every possible asynchronous flow, e.g. RxJS streams, generators, promises, sockets, etc.

Next Steps

Let's take a closer look at using custom injectors to simplify managing side effects.