Mobx: Using Observables instead of State, componentDidUpdate

Created on 10 Jul 2018  路  5Comments  路  Source: mobxjs/mobx

Hello. I replaced React's built in state / setState calls with a MobX observable I call $state, and do identical things with it. It is generally fantastic.

However, right now, I have ran into a specific little edge case which I know I can solve in a few fashions, but would be nicely handled by React built-ins, which is why I come here to ask if there seems to be a canonical solution from your view.

Specifically, I have two components, side-by-side in a row. One of them is a panel called LeftNav which can shrink or grow horizontally when it gets collapsed / expanded, and the other of them is an flex-box containing an SVG which is designed to fill its parent container but not block it from shrinking or growing; accordingly, it responds to window resize events in order to re-draw.

In this instance, the LeftNav component will collapse, and then the SVG's container will flex-grow into the space left behind; however, no resize event fired, and so the SVG stretched to fill its parents new width, which results in its next re-draw shrinking some of its elements vertically.

If I was doing using basic React, I could solve this quite easily:

class LeftNav extends React.Component {
  // forgive me if this signature is wrong
  componentDidUpdate(prevProps, prevState) {
    if (prevState.collapsed !== this.state.collapsed) {
      window.dispatchEvent(new Event('resize'));
    }
  }

  render() {
    const className = this.state.collapsed ? "LeftNav collapsed" : "LeftNav";

    return (
      <div className={className}>
        ...
      </div>
    );
}

And my SVG would correctly re-draw without its scaling behavior ever being visible.

However, in MobX, I do this instead like:

@observer
class LeftNav extends React.Component {
  @observable $state = {
    collapsed: this.props.initiallyCollapsed,
  };

  @action
  toggleCollapsed() {
    this.$state.collapsed = !this.$state.collapsed;
  }

  render() {
    const className = this.$state.collapsed ? "LeftNav collapsed" : "LeftNav";

    return (
      <div className={className}>
        ...
      </div>
    );
  }
}

Now, the componentDidUpdate hook gives me no visibility on what $state was before the last render, so I am put in an unenviable position of firing resize events even when the rerender was thanks to a different nav element being clicked, etc..

Is there any official way to do something analogous to componentDidUpdate here, such that I can tell whether the render was caused by the toggleCollapsed action or something else? From the excellent documentation, I could not find anything, so perhaps I have encountered a limitation.

Really, I'm quite open to suggestions on how this might be solved, even if it involves restructuring some code. This might be the first time I've really felt disadvantaged using MobX over default state, and so I feel as if I might be missing something here; to be honest, it might even be some kind of listener to the SVG's parent div, but for now I think this is the level I wish to solve it at.

Thanks for the package and thanks for any feedback you have here!

Most helpful comment

Ok, then you can use this:

this.disposer = reaction(
  () => this.$state.collapsed,
  () => this.resized = true
);

// ...

resized = false;
componentDidUpdate() {
    if (this.resized)
    {
        window.dispatchEvent(new Event('resize'));
        this.resized = false;
    }
}

Or you can use custom scheduler:

this.disposer = reaction(
  () => this.$state.collapsed,
  () => window.dispatchEvent(new Event('resize')),
  {
      scheduler: f => this.postRenderEvents.push(f),
  }
);

// ...

postRenderEvents = [];
componentDidUpdate() {
    if (postRenderEvents.length > 0) {
        for (const f of postRenderEvents)
            f();
        postRenderEvents.length = 0;
    }
}

All 5 comments

Use mobx.reaction (or autorun) in constructor or componentDidMount: mobx.reaction(() => this.$state.collapsed, () => window.dispatchEvent(new Event('resize')))

You should use componentDidMount as compomentWillUnmount is not guaranteed to be called unless componentDidMount has been called. Also remember to call the function returned from creating the reaction in componentWillUnmount.

Ah my friends, were only I so naive as to not have tried. Try it yourself, if you'd like to see why this fails:

@observer
class LeftNav extends React.Component {
  componentDidMount() {
    this.disposer = reaction(
      () => this.$state.collapsed,
      () => {
        console.log('Dispatched resize event');
        window.dispatchEvent(new Event('resize'));
      },
    );
  }

  componentWillUnmount() {
    this.disposer();
  }

  componentDidUpdate() {
    console.log('The render has just now finished, and so the event must be dispatched AFTER this.');
  }
}

And you will see the event is dispatched before the rerender is completed, so the 'resize' event is picked up before the LeftNav has finished actually updating its size. As far as I can tell, MobX queues all of these observable responses (reaction, computed, whatever), on the current event queue, and the re-render doesn't happen until the next one, if not even a bit later. Indeed, I think the reaction is intended to flush before the next re-render, so if you use a reaction to some thing occurring, your component state will be updated before it had a chance to rerender. In any case, this is why I specifically drew analogue to React's lifecycle: without some knowledge of how those renders were scheduled, it's impossible to draw a conclusion on when to fire the resize event.

Now, don't get me wrong, for practicality's sake I can just fire a resize event each time in componentDidUpdate, and if my other components are even halfway efficient this clearly will be a complete non-issue. It's just the cleanliness of the solution is not there.

Ok, then you can use this:

this.disposer = reaction(
  () => this.$state.collapsed,
  () => this.resized = true
);

// ...

resized = false;
componentDidUpdate() {
    if (this.resized)
    {
        window.dispatchEvent(new Event('resize'));
        this.resized = false;
    }
}

Or you can use custom scheduler:

this.disposer = reaction(
  () => this.$state.collapsed,
  () => window.dispatchEvent(new Event('resize')),
  {
      scheduler: f => this.postRenderEvents.push(f),
  }
);

// ...

postRenderEvents = [];
componentDidUpdate() {
    if (postRenderEvents.length > 0) {
        for (const f of postRenderEvents)
            f();
        postRenderEvents.length = 0;
    }
}

Absolutely fantastic, thanks.

Edit: Just a thought, if you have access to the documentation for reaction, it might be worth explaining that this is amongst its capabilities. I read the page but left without any idea what I could do with the scheduler. It's a very clean solution too.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

ansarizafar picture ansarizafar  路  4Comments

josvos picture josvos  路  3Comments

etinif picture etinif  路  3Comments

Niryo picture Niryo  路  3Comments

rodryquintero picture rodryquintero  路  3Comments