2

I have a stream of events which is grouped by name.

Every resulting observable is passed as events$ prop to a Folder component, which subscribes to it in componentDidMount().

The problem I'm experiencing is that, Folder's observer is missing the first event.

To reproduce, click on Add event. You'll see that Folder renders undefined, and doesn't update it to the data in the emitted event. Playground

const events$ = new Rx.Subject();

class Folder extends React.Component {
  constructor() {
    super();
    this.state = {};
  }

  componentDidMount() {
    this.props.events$.subscribe(e => {
      this.setState({
        name: e.name,
        permissions: e.permissions
      }); 
    });
  }

  componentWillUnmount() {
    this.props.events$.unsubscribe();
  }

  render() {
    const { name, permissions } = this.state;
    
    return (
      <div>
        {`[${permissions}] ${name}`}
      </div>
    );
  }
}

class App extends React.Component {
  constructor(props) {
    super();
    this.eventsByName$ = props.events$.groupBy(e => e.name);
    this.state = {};
  }
  
  componentDidMount() {
    this.eventsByName$.subscribe(folderEvents$ => {
      const folder = folderEvents$.key;
     
      this.setState({
        [folder]: folderEvents$
      });
    });
  }
  
  onClick = () => {
    this.props.events$.next({
      type: 'ADD_FOLDER',
      name: 'js',
      permissions: 400
    });
  };
  
  render() {
    const folders = Object.keys(this.state);
    
    return (
      <div>
        <button onClick={this.onClick}>
          Add event
        </button>
        <div>
          {
            folders.map(folder => (
              <Folder events$={this.state[folder]} key={folder} />
            ))
          }
        </div>
      </div>
    );
  }
}

ReactDOM.render(
  <App events$={events$} />,
  document.getElementById('app')
);
<head>
  <script src="https://unpkg.com/rxjs@5.2.0/bundles/Rx.js"></script>
  <script src="https://unpkg.com/react@15.4.2/dist/react.js"></script>
  <script src="https://unpkg.com/react-dom@15.4.2/dist/react-dom.js"></script>
</head>
<body>
  <div id="app"></div>
</body>

To get the missing event, I used ReplaySubject(1): Playground

const events$ = new Rx.Subject();

class Folder extends React.Component {
  constructor() {
    super();
    this.state = {};
  }

  componentDidMount() {
    this.props.events$.subscribe(e => {
      this.setState({
        name: e.name,
        permissions: e.permissions
      });
    });
  }

  componentWillUnmount() {
    this.props.events$.unsubscribe();
  }

  render() {
    const { name, permissions } = this.state;
    
    return (
      <div>
        {`[${permissions}] ${name}`}
      </div>
    );
  }
}

class App extends React.Component {
  constructor(props) {
    super();
    this.eventsByName$ = props.events$.groupBy(e => e.name);
    this.state = {};
  }
  
  componentDidMount() {
    this.eventsByName$.subscribe(folderEvents$ => {
      const folder = folderEvents$.key;
     
      const subject$ = new Rx.ReplaySubject(1);

      folderEvents$.subscribe(e => subject$.next(e));
      
      this.setState({
        [folder]: subject$
      });
    });
  }
  
  onClick = () => {
    this.props.events$.next({
      type: 'ADD_FOLDER',
      name: 'js',
      permissions: 400
    });
  };
  
  render() {
    const folders = Object.keys(this.state);
    
    return (
      <div>
        <button onClick={this.onClick}>
          Add event
        </button>
        <div>
          {
            folders.map(folder => (
              <Folder events$={this.state[folder]} key={folder} />
            ))
          }
        </div>
      </div>
    );
  }
}

ReactDOM.render(
  <App events$={events$} />,
  document.getElementById('app')
);
<head>
  <script src="https://unpkg.com/rxjs@5.2.0/bundles/Rx.js"></script>
  <script src="https://unpkg.com/react@15.4.2/dist/react.js"></script>
  <script src="https://unpkg.com/react-dom@15.4.2/dist/react-dom.js"></script>
</head>
<body>
  <div id="app"></div>
</body>

Now, when Add event is clicked, Folder's observer sees the event and properly renders its data.

This feels a bit hacky to me.

Is there a better way to organize the code to avoid the missing event issue?

This question might help.

Community
  • 1
  • 1
Misha Moroshko
  • 166,356
  • 226
  • 505
  • 746
  • I fiddled around with your code and it looks like the first time the stream is not really set up correctly but the `groupBy` operator swallows the error. Here is a "version" that shows the error: https://www.webpackbin.com/bins/-KeiIf-k2_Utqbn4Zw2u – Sebastian Sebald Mar 08 '17 at 15:19
  • It seems like a timing issue between React and Rxjs. – Sebastian Sebald Mar 08 '17 at 15:20
  • 1
    @SebastianSebald Have a look at [this question](http://stackoverflow.com/q/42659326/247243). – Misha Moroshko Mar 08 '17 at 16:51

2 Answers2

2

This is truly a timing issue. Normal Subjects do not cache, buffer, or otherwise keep anything that is emitted through them, so what is happening is this:

  1. Click button
  2. onClick calls events$.next(event) and it notifies any subscribers, which is currently only eventsByName$, which is listening because it calls groupBy on them and was subscribed earlier in componentDidMount
  3. eventsByName$ subscriber calls setState, which is batched because this the callstack leading to here was synchronous starting from a React synthetic event as described in my answer to your previous question
  4. React waits until the onClick callstack returns, and flushes the setState buffer
  5. React rerenders (remember, all the event$ subscribers have already been notified before this and the <Folder> component is not one of them yet because it hasn't even been created.
  6. A new <Folder> component is created, passing in its event$
  7. That new <Folder> mounts, leading it to subscribe to event$ it was provided, but missing a chance to catch the ADD_FOLDER event you emitted previously.

This is why using ReplaySubject worked, it replays the last event you missed.


Overall, I would suggest moving away from this event pattern. Since this is a contrived example to demonstrate the issue I can't be sure what problem you're trying to solve using these style, but generally speaking it has similarities to Redux. If this pattern is neccesary for your app, you might consider using Redux or adopting the same patterns with just RxJS using a .scan()--an example of which is here. It's hard to say whether you need this though, cause the example you gave can easily be done without RxJS and would be more clear with just normal React onClick -> setState -> rerender -> pass state as props (without any Rx in there)

I highly recommend against reimplementing the wheel though--redux has a vibrant middleware ecosystem and excellent Dev Tools that you'd lose rolling your own. Also, you can still use RxJS with Redux. I maintain a library just for that, redux-observable.

Community
  • 1
  • 1
jayphelps
  • 15,276
  • 3
  • 41
  • 54
1

I can not really tell you what exactly is causing this, although it seems like a timing issue, because the Subject is only correctly initialized after the first click event.

But, for a "better" pattern: I would suggest to use a similar pattern to Redux. Meaning, creating a HOC that connects to your event stream. There is already an example implementation: https://github.com/MichalZalecki/connect-rxjs-to-react

The difference between your implementation and the linked one is that it uses context instead of state. Maybe it is safer to use context in this scenario, because the context is never destroyed during the lifetime of a React app.

Sebastian Sebald
  • 16,130
  • 5
  • 62
  • 66