Orchestral conductor managing redux-cycles side effects

Redux-cycles: Orchestrating Reactive Side Effects in Redux

The Gray Cat
The Gray Cat

Redux Cycles is a powerful middleware that brings the reactive programming paradigm of Cycle.js to Redux applications. It offers developers a declarative way to handle side effects, improving the testability and maintainability of their code. By leveraging the principles of functional reactive programming, redux-cycles allows you to manage complex asynchronous operations with ease.

Harmonizing Redux and Cycle.js

At its core, redux-cycles acts as a bridge between Redux and Cycle.js. It intercepts Redux actions and allows you to handle them using Cycle.js in a pure, data-flow manner. This approach pushes side effects to the edges of your application, leaving your main logic to operate on pure streams of data.

Key Features

  • Declarative Side Effects: Write your side effects as pure functions that transform streams of actions.
  • Improved Testability: Easily test your side effect logic without complex mocking.
  • Separation of Concerns: Clearly separate your application’s logic from its side effects.
  • Reactive Programming Model: Leverage the power of reactive streams to handle complex async flows.

Setting Up the Stage

To get started with redux-cycles, you’ll need to install it along with Cycle.js run function:

npm install --save redux-cycles @cycle/run

Once installed, you can set up the middleware in your Redux store:

import { createStore, applyMiddleware } from 'redux';
import { createCycleMiddleware } from 'redux-cycles';
import { run } from '@cycle/run';

const cycleMiddleware = createCycleMiddleware();
const { makeActionDriver, makeStateDriver } = cycleMiddleware;

const store = createStore(
  rootReducer,
  applyMiddleware(cycleMiddleware)
);

function main(sources) {
  // Your Cycle.js logic here
}

run(main, {
  ACTION: makeActionDriver(),
  STATE: makeStateDriver(),
});

Composing Your First Cycle

Let’s look at a simple example of how to use redux-cycles to handle an asynchronous action:

import xs from 'xstream';

function main(sources) {
  const ping$ = sources.ACTION
    .filter(action => action.type === 'PING')
    .mapTo({ type: 'PONG' });

  const request$ = sources.ACTION
    .filter(action => action.type === 'FETCH_USER')
    .map(action => ({
      url: `https://api.github.com/users/${action.payload}`,
      category: 'users',
    }));

  const response$ = sources.HTTP
    .select('users')
    .flatten()
    .map(response => ({
      type: 'FETCH_USER_FULFILLED',
      payload: response.body,
    }));

  return {
    ACTION: xs.merge(ping$, response$),
    HTTP: request$,
  };
}

In this example, we’re handling two types of actions: a simple ‘PING’ action that immediately responds with a ‘PONG’, and a ‘FETCH_USER’ action that triggers an HTTP request.

Advanced Orchestration

Redux-cycles really shines when dealing with complex asynchronous flows. Let’s look at a more advanced example:

import xs from 'xstream';
import sampleCombine from 'xstream/extra/sampleCombine';

function main(sources) {
  const state$ = sources.STATE;

  const increment$ = sources.ACTION
    .filter(action => action.type === 'INCREMENT_IF_ODD')
    .compose(sampleCombine(state$))
    .filter(([action, state]) => state.counter % 2 !== 0)
    .mapTo({ type: 'INCREMENT' });

  const fetchUserAndRepos$ = sources.ACTION
    .filter(action => action.type === 'FETCH_USER_AND_REPOS')
    .map(action => xs.combine(
      sources.HTTP.select('users').flatten(),
      sources.HTTP.select('repos').flatten()
    ))
    .flatten()
    .map(([user, repos]) => ({
      type: 'FETCH_COMPLETE',
      payload: { user, repos },
    }));

  const request$ = sources.ACTION
    .filter(action => action.type === 'FETCH_USER_AND_REPOS')
    .map(action => [
      {
        url: `https://api.github.com/users/${action.payload}`,
        category: 'users',
      },
      {
        url: `https://api.github.com/users/${action.payload}/repos`,
        category: 'repos',
      },
    ]);

  return {
    ACTION: xs.merge(increment$, fetchUserAndRepos$),
    HTTP: request$.flatten(),
  };
}

This example demonstrates how redux-cycles can handle more complex scenarios, such as conditional state updates and parallel HTTP requests.

Testing Your Cycles

One of the major benefits of using redux-cycles is the ease of testing. Since your cycles are pure functions that operate on streams, you can test them without needing to mock external dependencies:

import xs from 'xstream';
import { mockTimeSource } from '@cycle/time';

describe('main', () => {
  it('should map PING to PONG', (done) => {
    const Time = mockTimeSource();
    const ACTION = {
      '-a-': { type: 'PING' },
    };
    const expected = {
      '-b-': { type: 'PONG' },
    };

    const sources = {
      ACTION: Time.diagram(ACTION.a),
    };

    const sinks = main(sources);

    Time.assertEqual(sinks.ACTION, Time.diagram(expected.b));
    Time.run(done);
  });
});

This test uses @cycle/time to create a virtual time source, allowing us to test time-based behavior deterministically.

Conclusion

Redux-cycles offers a powerful way to manage side effects in Redux applications. By leveraging the principles of reactive programming, it allows developers to write more declarative, testable, and maintainable code. While it does introduce a new paradigm that may take some time to master, the benefits in terms of code organization and testability make it a valuable tool in the Redux ecosystem.

For those looking to explore more React and Redux related topics, you might find these articles interesting:

By incorporating redux-cycles into your Redux applications, you can create more robust and scalable solutions for handling complex asynchronous flows and side effects.

Comments