redux-pixies
v0.3.8
Published
The magical asynchronous Redux library
Downloads
9,818
Maintainers
Readme
Redux Pixies
The magical Redux side-effects library
Table of contents
Introduction
Pixies are little processes that run in the background, monitoring your Redux store and handling asynchronous side-effects. Pixies are a lot like React components, but instead of managing the DOM, pixies manage everything else.
Pixies are state-based, rather than action-based. A pixie's job is to compare the state of the Redux store with the real world and fix anything that is out of sync. For example, a pixie might notice when the user is on a search page but has no search results, so it fetches results from the server in response. If the user leaves the search page before the results come in, the pixie can notice that too and cancel the request.
This is much easier than action-based side-effect approaches like redux-thunk or redux-saga. In the example above, no matter what Redux actions cause the user to enter the search page, the pixie will always notice the missing results and perform the fetch. With the traditional approach, the programmer must manually wire side effects into any action that might enter or leave the search page. This is a lot more work, and far more error-prone.
Example
import { attachPixie } from 'redux-pixies'
const searchPixie = () => async (props) => {
// If the user is on the search page, but has no search results,
// go ahead and fetch those:
if (props.state.onSearchPage && !props.state.hasSearchResults) {
const results = await fetchSearchResults(props.state.searchTerm)
props.dispatch({ type: 'SEARCH_FETCHED', payload: results })
}
}
// Attach the pixie to the Redux store, causing it to run each time
// the state changes (unless the pixie is already doing a fetch):
const destroy = attachPixie(redux, searchPixie)
Writing Pixies
A pixie is just a function that returns an update
function and a destroy
function:
function examplePixie (input) {
return {
update (props) {},
destroy () {}
}
}
The update
function receives props
, which change along with the app's state. The pixie's job is to examine the props
and perform any work that needs to happen in response. If this function returns a promise, the runtime will wait for the promise to resolve before calling update
again. This ensures that pixie won't accidentally start the same work twice.
Pixies can be destroyed at any time, even while their async update
function is still running. The pixie can use the destroy
function to free resources or to cancel any work that update
is doing.
If the destroy
function isn't needed, the pixie can just return the update
function directly. Combined with ES2015 arrow functions, this allows pixies to be very compact:
const examplePixie = ({ onError, onOutput }) => props => {
// Do the update here...
}
The input
parameter is an object with the following properties:
onError
onOutput
props
nextProps
waitFor
Reporting errors
If a pixie encounters an error, it should call input.onError
. Pixies can call this function at any time, including from within timers and event handlers. If a pixie function throws an exception, or if update
returns a rejected promise, that error will also be captured and passed along to onError
.
Calling onError
shuts down the pixie, calling its destroy
method and preventing it from receiving further update
calls. The next time the props change, a new pixie will be created in the destroyed pixie's place. If a pixie does not want this behavior, it should handle the error gracefully itself instead of calling onError
.
Sharing data
Sometimes pixies create resources that they would like to share with others. For example, one pixie might maintain a WebGL context that several other pixies might need to draw on.
To do this, a pixie can call input.onOutput
at any time to share some data. The latest value passed to onOutput
becomes the pixie's output, and other pixies can see it via the props.output
structure.
Accessing props from event handlers
Sometimes a pixie will create & manage a long-running resource with callbacks. For example, a pixie might open a WebSocket, which periodically calls the pixie back with incoming messages. To access the current props within these contexts, every pixie receives three additional helpers:
input.props
- This property stays in-sync with the latest props, regardless of whenupdate
has been called.input.nextProps()
- Returns a promise that resolves when the props change, or rejects when the pixie is destroyed.input.waitFor(props => result)
- Returns a promise that resolves when the provided result is non-null, or rejects when the pixie is destroyed. This can be used to wait for outside resources to become available.
Pixies can pass the rejected error to isPixieShutdownError
to determine if the promise was rejected because the pixie was destroyed.
Managing pixies
Starting pixies
To actually use a pixie, attach it to a Redux store using attachPixie
. This function accepts a Redux store as its first parameter and a pixie as its second parameter:
const destroy = attachPixie(reduxStore, pixieFunction)
Now the pixie's update
function will be called every time the Redux store changes. The pixie will receive a props
object with state
and dispatch
taken from the Redux store.
The attachPixie
function returns a destroy
function, which destroys the pixie and disconnects it from the Redux store.
For extra control, you can optionally pass your own onError
and onOutput
callbacks to attachPixie
:
const destroy = attachPixie(
reduxStore,
pixie,
error => console.error('Pixie error:', error),
output => console.info('Pixie output:', output)
)
If you would like to use pixies without a Redux store, such as for unit-testing, use startPixie
:
const instance = startPixie(pixie)
instance.update(props)
instance.destroy()
The startPixie
function filters the update
calls, so you can call the returned update
function as often as you like. As with attachPixie
, you can also provide your own onError
and onOutput
callbacks.
Customizing props
The redux-pixies
library provides a filterPixie
function, which makes it possible to customize the props going into a pixie:
const FilteredPixie = filterPixie(
subsystemPixie,
props => ({ login: props.output.login })
})
In this example, the subsystem pixie receives just the login
object from the outside world; the other props are filtered out. Since redux-pixies
avoids unnecessary update
calls when the props are identical, filtering the props down to the bare minimum can avoid unnecessary update
calls for unrelated state changes.
If the props are undefined, filterPixie
will shut down the inner pixie. Once the props exist again, filterPixie
will restart the pixie. This provides a declarative way to control a pixie's lifetime.
Combining pixies
The redux-pixies
library provides a combinePixies
function. This function works a lot like the combineReducers
function from Redux. It accepts an object where the keys are the names of each pixie, and the values are the pixie functions.
const appPixie = combinePixies({
search: searchPixie,
login: loginPixie
})
The props passed into the combined pixie will be passed along to the child pixies unchanged.
If any of the child pixies produce output, it will be available in props.output
under the pixie's name. So, if the login pixie in this example calls onOutput
, the search pixie can see that data as props.output.login
.
Replicating pixies
For managing lists of things, redux-pixies
provides a mapPixie
function. This function creates a pixie for each item in a list of id's. As the list changes, this function will automatically start and stop pixies in response, so every id has its own pixie.
const chatListPixie = mapPixie(
chatPixie,
// Grabs the id list from the props:
props => props.state.activeChatIds,
// Each item pixie receives its own custom props:
(props, id) => ({ ...props, id, avatar: props.state.avatars[id] })
)
Testing pixies
Since pixies are directly responsible for talking to the outside world, the best way to test them is using mocks. To do this, write your pixies to only use IO resources passed in through props
. For example, the following code passes the browser's fetch
function into a pixie:
const injectedPixie = filterPixie(
serverFetchPixie,
props => { ...props, fetch: window.fetch }
)
Now, when the time comes to unit-test this code, just pass a mock fetch
function into the props instead:
const testPixie = startPixie(serverFetchPixie)
testPixie.update({ fetch: fetchMock, dispatch: done })
testPixie.destroy()
Pixie enhancers
Adding output
to props
To intercept a pixie's onOutput
callback, making the output available to the pixie as props.output
, pass the pixie through the reflectPixieOutput
function. You can use this enhancer at any point in your pixie tree to limit the scope at which output becomes visible to child pixies.
Catching pixie errors
To intercept a pixie's onError
callback, pass the pixie through the catchPixieError
function. This will shut down the child pixie and give you a chance to handle the error. The next time the props change, catchPixieError
will create a new pixie in the destroyed pixie's place:
const safePixie = catchPixieErrors(
subsystemPixie,
// Called whenever there is an error:
(error, props) => props.dispatch({
type: SUBSYSTEM_FAILED,
payload: error,
error: true
})
)
Using this enhancer throughout your tree of pixies can limit a failed pixie's destruction to just the affected subsystem.
Implementation details
Since pixies are just functions that return other functions, there is nothing preventing you from calling them directly yourself. Although this will produce a working pixie instance, many features will be missing compared to using startPixie
. Specifically, startPixie
passes the pixies through the reflectPixieOutput
and catchPixieError
enhancers to get the default onOutput
and onError
behavior.
Wild vs. Tame Pixies
There are actually two types of pixies - wild pixies and tame pixies. A wild pixie is the kind you write directly. It has the following behaviors and expectations:
- The pixie can either return an object with
update
anddestroy
functions, or a bareupdate
function. - If
update
returns a promise, it will not be called again until the promise resolves. - Any pixie function can throw an exception, and they will all be captured and passed to
onError
.
A tame pixie, on the other hand, has the following behaviors and expectations:
- The pixie will always return an object with
update
anddestroy
functions. - Pixie functions will never throw exceptions or return promises.
Wild pixies are obviously a lot easier to create, while tame pixies are a lot easier to use. To turn a wild pixie into a tame pixie, pass it through the tamePixie
function. All the functions in this library automatically call tamePixie
on their inputs, so this is not something you would normally do yourself.
Recursion rules
To make things run smoothly, pixies must follow some rules. These apply to both tame and wild pixies:
- The
update
function must never call itself recursively. Ifupdate
callsonOutput
, which changes the props,update
must not be called again until the previousupdate
returns. - Once
destroy
is called, no further calls todestroy
orupdate
may occur, even ifdestroy
callsonError
oronOutput
. - The
destroy
function can be called at any time, even whileupdate
is running. This is becauseupdate
can callonError
.
Functional purity
The functions for deriving the props
must be pure, meaning they don't produce any side-effects or modify any data. This allows the pixie system to avoid calls to update
when things haven't actually changed. Besides being a nice optimization, this prevents some infinite loop scenarios where update
calls onOutput
which calls update
again.