Use RxJS with React

@MichalZalecki

Reactive
means
awesome

promises

await/async

RxJS

callbacks

async awesomeness

reactive

\rē-ˈak-tiv\

done in response to a problem or situation

 merriam-webster.com

Reactive JavaScript

http://slides.com/michalzalecki/reactive-javascript

Reactive state

// createState.js

import Rx from "rxjs";

function createState(reducer$,
  initialState$ = Rx.Observable.of({})) {
  
  return initialState$
    .merge(reducer$)
    .scan((state, reducer) => reducer(state))
    .publishReplay(1)
    .refCount();
}

export default createState;

Reactive state

it("creates state$ with initialState$", done => {
  const reducer$ = new Rx.Subject();
  const initialState$ = Rx.Observable.of({ counter: -10 });
  const reducer = state => ({ ...state, counter: state.counter + 1 });
  const state$ = createState(reducer$, initialState$);

  state$.toArray().subscribe(results => {
    expect(results).toEqual([
      { counter: -10 },
      { counter: -9 },
      { counter: -8 },
      { counter: -7 },
    ]);
  }, () => {}, done);

  reducer$.next(reducer);
  reducer$.next(reducer);
  reducer$.next(reducer);
  reducer$.complete();
});

Actions, ActionCreators, Constants…

// CounterActions.js

import Rx from "rxjs";

const CounterActions = {
  increment$: new Rx.Subject,
  decrement$: new Rx.Subject,
};

export default CounterActions;

Reducer($)

// CounterReducer.js

import Rx from "rxjs";
import CounterActions from "app/actions/CounterActions";

const CounterReducer$ = Rx.Observable.merge(
  CounterActions.increment$.map((n = 1) =>
    state => ({ ...state, counter: state.counter+n })),

  CounterActions.decrement$.map((n = 1) =>
    state => ({ ...state, counter: state.counter-n }))
);

export default CounterReducer$;

Reducer($)

// state.js

import Rx from "rxjs";
import createState from "app/rx-state/createState";
import CounterReducer$ from "app/reducers/CounterReducer";
// import OtherReducer$ from "app/reducers/OtherReducer";
// import Other2Reducer$ from "app/reducers/Other2Reducer";
// import Other3Reducer$ from "app/reducers/Other3Reducer";
// ...

const reducer$ = Rx.Observable.merge(
  CounterReducer$
  // OtherReducer$,
  // Other2Reducer$,
  // Other3Reducer$
);

const initialState$ = Rx.Observable.of({ counter: 0 });

export default createState(reducer$, initialState$);

testReducer

it("increments counter", done => {
  testReducer(CounterReducer$, [1, 4, 5], 
    { counter: 0 }, v => v.counter)
      .subscribe(() => {}, () => {}, done);

  CounterActionsMock.increment$.next(1);
  CounterActionsMock.increment$.next(3);
  CounterActionsMock.increment$.next(1);
  CounterActionsMock.increment$.next(1);
});

testReducer

function testReducer($reducer, values, initialState = {},
  selector = v => v) {

  const nextValues = [...values];

  const observable = $reducer
    .scan((state, reducer) => reducer(state), initialState)
    .map(selector)
    .take(values.length);

  observable.subscribe({
    next(val) { expect(val).toEqual(values.shift()) },
    error(err) { throw err },
  });

  return observable;
}

export default testReducer;

connect

// connect.js

import React from "react";

function connect(state$, selector = (state) => state) {
  return function wrapWithConnect(WrappedComponent) {
    return class Connect extends React.Component {
      constructor(props) {
        super(props);
        state$.take(1).map(selector).subscribe(state => this.state = state);
      }

      componentDidMount() {
        this.subscription = state$.map(selector).subscribe(::this.setState);
      }

      componentWillUnmount() {
        this.subscription.unsubscribe();
      }

      render() {
        return (
          <WrappedComponent {...this.state} {...this.props} />
        );
      }
    };
  }
}

export default connect;

connect

it("creates connected component with selector", () => {
  const selector = state => ({ counter: state.counter*2 });
  const WrappedComponent = connect(state$, selector)(Component);
  const tree = TestUtils.renderIntoDocument(<WrappedComponent />);
  const heading = TestUtils
    .findRenderedDOMComponentWithClass(tree, "heading");

  expect(heading.textContent).toEqual("");
  state$.next({ counter: 10 });
  expect(heading.textContent).toEqual("20");
  state$.next({ counter: 20 });
  expect(heading.textContent).toEqual("40");
});

Component

// Counter.jsx

import React from "react";
import state$ from "app/rx-state/state";
import connect from "app/rx-state/connect";
import CounterActions from "app/actions/CounterActions";

class Counter extends React.Component {
  render() {
    return (
      <div>
        <h1>{ this.props.counter }</h1>
        <hr/>
        <button onClick={ () => this.props.increment(1) }>+</button>
        <button onClick={ () => this.props.increment(10) }>+10</button>
        <button onClick={ () => this.props.decrement(1) }>-</button>
        <button onClick={ () => this.props.decrement(10) }>-10</button>
      </div>
    );
  }
}

export default connect(state$, state => ({
  counter: state.counter,
  increment(n) { CounterActions.increment$.next(n) },
  decrement(n) { CounterActions.decrement$.next(n) }
}))(Counter);

Component

it("increments by 10 on \"+10\" button click", () => {
  const increment = jasmine.createSpy();
  const tree = TestUtils
    .renderIntoDocument(
      <Counter counter={10} increment={increment} decrement={() => {}} />
    );
  const button = TestUtils
    .findRenderedDOMComponentWithClass(tree, "counter__button--i10");
  TestUtils.Simulate.click(button);
  expect(increment).toHaveBeenCalledWith(10);
});

Async

UserActions.fetch$.flatMap(userId => {
  return Rx.Observable.ajax(`/users/${userId}`)
});
UserActions.fetch$.concatMap(userId => {
  return Rx.Observable.ajax(`/users/${userId}`)
    .retryWhen(err$ => err$.delay(1000).take(10));
});

Questions?

react-form

import Superform from "react-superform";

class MyForm extends Superform {
  onSuccessSubmit(data) {
    console.log(data);
  }

  onErrorSubmit(errors, data) {}

  render() {
    return (
      <form noValidate onSubmit={ this.handleSubmit.bind(this) }>
        <input
          type="email" 
          ref="email" 
          name="email" 
          valueLink={ this.linkStateOf("email") } 
          required
        />
        <p className="error">{ this.getErrorMessageOf("email") }</p>
        <input type="submit" />
      </form>
    );
  }
}

ReactDOM.render(<MyForm />, document.getElementById("root"));