Skip to content

btford/react-palm

Repository files navigation

version build downloads

react-palm

A cohesive strategy for managing state, handling side effects, and testing React Apps.
yarn add react-palm

What is a Task?

Tasks can be understood in terms of their relationship to Promises. A Promise represents a future value, but for which the computation has already begun. A Task represents a future computation, or future side effect. It's like a function that you haven't run yet. Tasks are lazy, where Promises are eager.

Why? By capturing side effects this way, we can easily test them without having to build up a large suite of mocks. Code written this way is easy to test.

// Promise -> new Promise( () => window.fetch('example.com') ) // Promise -> new Promise( () => Math.random() ) // Promise -> new Promise( () => localStorage )

// Task -> () => new Promise( ... )

Task Redux Middleware

Tasks easily adapt to redux using the provided middleware. This allows you to build a state machine from Actions and Tasks. Make side effects a responsibility of the reducer while keeping the reducer pure.

Why does it matter?

By capturing side effects this way, we can easily test them without having to build up a large suite of mocks. The computation (when you call task.run()) might include a side-effect, but as long as the task is just sitting there, it's pure. Keeps reducer pure. At the same time owns the entire flow of doing XHR.

Setup

Add the taskMiddleware to your store, or the tasks handlers won't get called.

import { createStore, applyMiddleware, compose } from 'redux'
import { taskMiddleware } from 'react-palm'

import reducer from './reducer'

// using createStore
const store = createStore(reducer, applyMiddleWare(taskMiddleware))

// using enhancers
const initialState = {}
const middlewares = [taskMiddleware]
const enhancers = [
  applyMiddleware(...middlewares)
]

const store = createStore(reducer, initialState, compose(...enhancers))

Usage

Task.fromPromise

Here is a sample of what a delay task which triggers an action after a specified amount of time would look like.

import Task from 'react-palm/tasks';

export const DELAY = Task.fromPromise((time) =>
  new Promise(resolve => window.setTimeout(resolve, time)), 
  
  'DELAY'
);

You can use the task in your reducer like this:

import { withTask } from 'react-palm'
import { handleActions, createAction } from 'react-palm/actions'

import { DELAY } from './tasks/delay'

export const incrementWithDelay = createAction('DELAY_INCREMENT')
const increment = createAction('INCREMENT')

handleActions({
  DELAY_INCREMENT: state =>
    withTask(state, DELAY(1000).map(increment)),

  INCREMENT: state => state + 1
}, 0)

Dispatching incrementWithDelay will wait one second, then increment our counter state.

The call to .map tells us to wrap the result of the task in an INCREMENT action.

In the above example, we directly pass state as the first argument to withTask. Whatever you pass as the first argument will become the updated state, so you can update your state before the task is executed if you want. This might be useful to update a loading spinner, for instance.

Task.fromCallback

Wraps a node-style callback in a Task. fromPromise takes a function, which recieves the argument that the task is called with, and a node-style callback function (with type (err, res) => void), where the first argument represents the error when present (or null when successful), and the second argument represents the success value (or null in the case of an error).

// DELAY is a task that recieves how long to wait (in ms), and that resolves
// with a Date object representing the current time after waiting.
export const DELAY = Task.fromCallback((time, cb) =>
  window.setTimeout(() => cb(null, new Date()), time), 
  
  'DELAY'
);

// delay 100ms, then do something using the current time.
DELAY(100).map(currentTime => ...);

This example is equal to the one above using Task.fromPromise.

Task.all

Like Promise.all, but for tasks. Given an array of tasks, returns a new task whose success value will be an array of results.

import Task from 'react-palm/tasks';

const FETCH = Task.fromPromise((url) => window.fetch(url), 'FETCH');

const FETCH_SEVERAL = Task.all([ FETCH('example.com'), FETCH('google.com') ])

FETCH_SEVERAL.bimap(([exampleResult, googleResult]) => ..., err => ...);

task.chain

chain lets you run one task immediately after another. task.chain accepts a function like: (success) => nextTask. This function recieves the success value of the first task, and should return another task to be run.

Using the tasks we defined above, we can create a task that first waits 100ms, and then issues an HTTP request:

const WAIT_AND_FETCH = DELAY(100).chain(() => FETCH('example.com'));

// The resultant Task from chain will have the success payload from FETCH
WAIT_AND_FETCH.bimap(httpResult => ..., err => ...);

When used with Redux, this is a good way to avoid having to create extra actions.

task.bimap

Provide transforms for the success and error payload of a task. Bimap takes

bimap can be chained multiple times:

task
  .bimap(result => result + 1, error => 'Error: ' + error)
  .bimap(result => 2 * result, error => error + '!!')

bimap preserves composition, so the above is the same as writing:

task
  .bimap(result => 2 * (result + 1), error => 'Error: ' + error + '!!')

When using Tasks with Redux, we typically use bimap to transform the result of a Task into actions to be dispatched on completion.

Testing

We designed react-palm with testing in mind. Since you probably don't want to create API calls in a testing environment, we provide a drainTasksForTesting utility that will remove all the tasks from the queue and return them.

You can now assert that they have the expected type and payload.

import { drainTasksForTesting, succeedTaskInTest, errorTaskInTest } from 'react-palm'

import reducer, { incrementWithDelay } from './reducer'
import DELAY from './tasks/delay'

test('The delay task should be valid', t => {
  const state = reducer(42, incrementWithDelay())
  const tasks = drainTasksForTesting()

  t.is(state, 42)
  t.is(tasks.length, 1)
  t.is(tasks[0].type, DELAY)
  t.is(tasks[0].action.type, 'INCREMENT')

 
 // test success
 const successState = reducer(newState, succeedTaskInTest(task1, mockSuccessResult));
 t.deepEqual(successState, expectedState, ‘State should be updated when task succeed');

 // test LOAD_FILE_TASK error
 const errorState = reducer(nextState, errorTaskInTest(task1, mockErrorResult));
 t.deepEqual(errorState, expectedErrorState, ‘State should be updated when task errored);

 t.is(newState, 43)
})

You can also have a look to the example directory for a complete use-case.

FAQ

Strategy? Framework? Library?

It's unlikely that you'll create a cohesive architecture if you piecemeal add requirements to an existing design.

react-palm takes a "subtractive" approach; we start with a full set of concerns and make sure that they work well together before breaking them up. This means that as your app grows, you won't have to rethink everything.

Should I use this?

Ideally, you should use Elm or PureScript. This architecture is the closest thing to Elm I've managed to make within the constraints of JavaScript, React, and Redux. I created it as a stop-gap for specific applications that I work on. It contains trade-offs that may not be generally useful.

  • Elm, a friendly, functional, compile-to-JS language.
  • PureScript, a feature-rich, functional compile-to-JS language.
  • Choo, a small, Elm-like framework for JavaScript.
  • redux-loop a library that provides a very literal translation of commands and tasks from Elm to Redux.

Developing

yarn

Publishing (to npm)

  1. Update the version in package.json manually or via npm version.

  2. Commit and push.

  3. Create tags for the new version. Either:

  1. Publish:

Run build and publish from the resulting dist folder to ensure built artifacts are included in the published package:

yarn build
cd dist
npm publish