The difference between Redux and Rx: three letters and everything

  • 8
    いいね
  • 2
    コメント
この記事は最終更新日から1年以上が経過しています。

I've seen this question come up multiple times now, and it's a question that doesn't really make sense -- it's like asking "what's the difference between pants and trees?". But this idea probably got planted in people's heads because there's so many terms that are alien to them they hear when trying to learn how to build modern web applications: React, Redux, Flux, "Reactive", Rx, Dispatcher, EventEmitter, Observables, etc.

So this article is going to go through how a basic application is written in Redux, and then how similar ideas can be used to write an application using RxJS.

The code

Starting blocks

We will use a simple application for our study: an application that displays a counter, and provides two buttons to increment and decrement the counter.

For this, we will use some shared bits:

import React from 'react';
import ReactDOM from 'react-dom';

We import React and ReactDOM for our application's view.

type State = {
  counter: number
};

We define a Flow type for the state of our application. This is just extra stuff that allows us to use Flow to validate some behaviors for our program before we run it.

type Callback = () => void;

This is a convenience type for saying that we will have functions that are called that do not return anything.

type ViewProps = {
  name: string,
  callbacks: {
    increment: Callback,
    decrement: Callback
  },
  state: State
};

This defines the type of the props that want to pass into our View.

const initState = {
  counter: 0
};

This is the initial state of our application that we will use.

function View(props: ViewProps) {
  return (
    <div>
      <h2>Hello, {props.name}!</h2>
      <h2>Count: {props.state.counter}</h2>
      <button onClick={props.callbacks.increment}>+</button>
      <button onClick={props.callbacks.decrement}>-</button>
    </div>
  );
}

This is a function that will produce React Elements that we can render on to our page. Notice that we have callbacks here in the props: we will call whichever callbacks are provided to our application when the buttons are clicked, leaving us free to specify whatever callback we want into the view.

Redux

You probably know the high level of what Redux does by now, but just in case you need a refresher: Redux allows you to manage your application state by having a projecting function that takes the current state of your application and some information about what changes need to be applied to produce a new state for your application. We will verify this with Flow.

import {createStore} from 'redux';

We bring in the createStore method from Redux.

type Action = {
  type: string,
  payload?: Object
};

In Redux, we have the concept of Actions, which are objects that identify what operation is to be performed, and an optional payload object that will specify additional information we need.

const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

These constants will be used for our Action.type property, so that we can match these directly for the same value.

function projectRedux(state: State = initState, action: Action): State {
  switch(action.type) {
    case INCREMENT:
      return Object.assign({}, state, {
        counter: state.counter + 1
      });
    case DECREMENT:
      return Object.assign({}, state, {
        counter: state.counter - 1
      });
    default:
      return state;
  }
}

This is the bulk of our application. We provide a function for projecting the state of our application using the old state and the action data. So is verified in our type signature: (State, Action) => State.

const reduxStore = createStore(projectRedux);

We create a Redux Store using the projecting function we just described.

const reduxCallbacks = {
  increment: function () {
    reduxStore.dispatch({
      type: INCREMENT
    });
  },
  decrement: function () {
    reduxStore.dispatch({
      type: DECREMENT
    });
  }
}

Mentioned above, we specify the callbacks that will be used in the view here. They call the Redux Store's dispatch method to notify the store to run the projecting function to get the new state, and then notify subscribers that the state has been updated.

function renderReduxApp() {
  const reduxState = reduxStore.getState();

  console.log('reduxState', reduxState);

  const view = (
    <View
      name='Redux'
      state={reduxState}
      callbacks={reduxCallbacks}
    />
  );

  ReactDOM.render(view, document.getElementById('app-redux'));
}

This is our basic function to get the state from our Redux Store, log it out so we can see, and then render this to #app-redux.

reduxStore.subscribe(function () {
  renderReduxApp();
});

We subscribe to the Redux Store events with a function that calls renderReduxApp.

renderReduxApp();

Our Store will not emit an event initially, since nothing will have been dispatched. We will have to call our render function manually at first to get this to work.

Rx

Rx is a library that is like lodash for asynchronous events, allowing you to think about events as elements in an array, which you can operate on using operators. You can map, filter, scan, and a whole lot of other things, in addition to being able to delay events that come in and whatnot as you please. In short, Rx is basically completely unrelated to Redux, despite the fact that there are only three letters different between the two. But why am I writing a post comparing the two? The secret: I'm not actually comparing Redux and Rx.

We should remember that we aren't using Redux in this version, and we can source information from multiple sources easily. In the Redux version, we get events from multiple sources: the increment button and the decrement button. We then add an event listener that will call dispatch on our Store, which queues up Actions to be flushed later. What if we could abstract this into multiple streams? We then need to merge these streams into one synchronous stream to provide a stream of our application state. Luckily for us, this is pretty easy.

import Rx from 'rx';

We import the Rx module.

type Project = (state: State) => State;

This type is for our projection functions below with scan. The function does have values in the closure that it will access, but the function will properly project the current state into the next state.

const increment$ = new Rx.Subject();
const decrement$ = new Rx.Subject();

We then have two subjects, which can have information sent to them, and represent multiple future values that might come out of this, like our button clicks. It's like a Redux Store in that you can send information to it, and you can subscribe to the values that come out.

function projectIncrement(): Project {
  return function (state: State): State {
    return Object.assign({}, state, {
      counter: state.counter + 1
    });
  };
}

function projectDecrement(): Project {
  return function (state: State): State {
    return Object.assign({}, state, {
      counter: state.counter - 1
    });
  };
}

These are separate projecting functions that we can use since we have multiple sources of information. We even could simply map over the streams to transform these into Actions, but since we have multiple sources, we don't need to differentiate the data coming in like when we only have a single source for data (e.g. the Store dispatch queue).

const rxCallbacks = {
  increment: function () {
    increment$.onNext();
  },
  decrement: function () {
    decrement$.onNext();
  }
};

Similar to the reduxCallbacks we defined above, except we have multiple streams now. onNext tells the subject to queue up the object for emitting the event.

const rxState$ = Rx.Observable
  .merge(
    increment$.map(projectIncrement),
    decrement$.map(projectDecrement)
  )
  .startWith(initState)
  .scan(function (state: State, project: Project) {
    return project(state);
  });

This is where everything happens, so let's go through this step by step.

Observable.merge lets us merge multiple streams together. In this case, we will merge streams of our increments and decrements. To get something useful out of this, we map over these streams with a function to produce the projecting functions that we need to work with.

startWith begins our stream with an initial value that we will use.

scan is a term more familiar with math people, Haskell people, and people who like scanners, but it's an operation that is like your plain old Array.prototype.reduce. In our case, we will pass in our current state and a projecting function to produce a new state to work with.

rxState$.subscribe(function (rxState: State) {
  console.log('rxState', rxState);

  const view = (
    <View
      name='Rx'
      state={rxState}
      callbacks={rxCallbacks}
    />
  );

  ReactDOM.render(view, document.getElementById('app-rx'));
});

We then subscribe to our state stream. In this case, because we started off our stream with an initial value, the observer function will be called with the initial state.

A biased conclusion

In the end, it kind of doesn't matter which approach you use. You will end up doing a lot of work if you ever want to do things that Rx operators can give you if you choose to write your application in Redux, or you might find that you use Rx streams to manage when you dispatch events with Redux. The thing I want you to take away from this is that Redux and Rx aren't related, but because Rx is a general purpose library, you can do just about anything you want with it.

The rigid structure of Redux may even prove to be very useful for you, just like how it has benefitted some folks I've talked to who have migrated from a plethora of vanilla Flux and Flux library approaches to Redux: when you set boundaries on what people can do, there's only so much they can break -- but that's a sad thought, because you should be able to trust your coworkers as people who can keep learning things and come up with good solutions to problems, not malicious forces that mostly contribute bugs and hacks to your codebase, but I digress.

My own biased perspective would be that I would want applications that work and can handle complex tasks written using Rx, but I would consider also using Redux. Besides, Redux users' focus on debugging tools and interactive debugging has made for some really cool results.

On the other hand, if completely programming with streams sounds fun and you wish you could use a cycle to hook input streams into outputs into inputs, try out Cycle.js. It's fun.

As always, if you made it this far, thanks for reading! Please give some feedback on this on Twitter: @jusrin00.

This was written on a 14-hour flight without WiFi and may contain lots of errors and meaningless babble. Why can't I get Netflix on flights? The world sucks.

TL;DR Use either or both.

Bonus

You can reuse your Redux projection/reducer if you want. It's easy:

+function incrementAction(): Action {
+  return {
+    type: INCREMENT
+  };
+}

+function decrementAction(): Action {
+  return {
+    type: DECREMENT
+  };
+}

 const rxState$ = Rx.Observable
   .merge(
-    increment$.map(projectIncrement),
-    decrement$.map(projectDecrement)
+    increment$.map(incrementAction),
+    decrement$.map(decrementAction)
   )
   .startWith(initState)
-  .scan(function (state, project) {
-    return project(state);
-  });
+  .scan(projectRedux);

Links

Direct source: https://github.com/justinwoo/redux-rx-fun/blob/f1dbe526cfedfd3a6b5d05d9a710cee82fbb0729/src/index.js
Github repo: https://github.com/justinwoo/redux-rx-fun/
ReactiveX: http://reactivex.io/
RxJS: https://github.com/Reactive-Extensions/RxJS
Redux: https://github.com/rackt/redux