7

I have a website with the following react-router and react-router-relay setup:

main.jsx:

<Router history={browserHistory} render={applyRouterMiddleware(useRelay)}>
  <Route path="/:classroom_id" component={mainView} queries={ClassroomQueries}>
    <IndexRoute component={Start} queries={ClassroomQueries} />
    <Route path="tasks" component={TaskRoot} queries={ClassroomQueries}>
      <IndexRoute component={TaskList} queries={ClassroomQueries} />
      <Route path="task/:task_id" component={TaskView} queries={ClassroomTaskQueries} />
      <Route path="task/:task_id/edit" component={TaskEdit} queries={ClassroomTaskQueries} />
    </Route>
  </Route>
</Router>

The site is a virtual classroom, in which the teacher can create tasks for the students. These tasks can also be edited.

When a user edits a task in TaskEdit and hits save, the changes are applied and the user redirected to TaskList. Now I want to give the user a "Your changes were saved!" message once a task was edited.

I have created a bootstrap alert component (<Alert>) for this exact purpose.

For illustration purposes, assume I have the following mainView component (check Router above):

mainView.js:

render() {
  <div>
    <Menu />
    <Row>
      <Col md={3}>
        <Sidebar />
      </Col>
      <Col md={9}>
        {this.props.children}  <-- Start, TaskRoot, etc go here
      </Col>
    </Row>
    <Alert someProps={...} />  <-- Popup component
  </div>
}

Normally, I would create this alert component outside of the active component and just pass some kind of function that displays the alert as a prop to the active component. Similar to what is shown here.

However, this only works for a specific component. But because <Alert> sits at the root-level of my page (along with menubar, sidebar, and other things that are static on all pages), I cannot pass this as a prop due to {this.props.children}.

Allow me to illustrate the flow I want to achieve once more:

  1. User saves edits to a task in <TaskEdit>.
  2. Edits are saved and user taken back to <TaskList>.
  3. Immediately after, I want <Alert> to trigger, and show a message.

I've seen other solutions suggesting that you can clone all children of a React element and apply the prop needed to open the alert, but because of the nature of react-router this doesn't really seem to work.

How can I accomplish the above? Is there a way for a component to be globally accessible? Please note that I don't want to recreate <Alert> in each component. Only one such component for the whole DOM.

Community
  • 1
  • 1
Chris
  • 48,555
  • 16
  • 95
  • 112

2 Answers2

3

Simple answer:

Put your Alert component once in your top level view, then use some pubsub or event bus lib to update it.

In your Alert component onComponentDidMount function you listen for a specific event and data and update its state accordingly (visibility and message)

In your Task save function you publish your event to the event bus.

More advance answer:

Use a React flux library https://facebook.github.io/flux/docs/overview.html

plus-
  • 40,996
  • 14
  • 55
  • 71
  • I was actuallly thinking about doing something like this at first. Not the most beautiful solution, but I guess there isn't one unless you use a library of some sort. Thanks alot! – Chris May 05 '16 at 13:42
2

A simple solution would be to use the context feature:

const MainView = React.createClass({

  childContextTypes: {
    triggerAlert: PropTypes.func.isRequired,
  },

  getChildContext() {
    return {
      triggerAlert: alert => this.setState({ alert }),
    };
  },

  getInitialState() {
    return {
      alert: null,
    };
  },

  render() {
    return (
      <div>
        {this.props.children}
        <Alert alert={this.state.alert} />
      </div>
    };
  },

});

const AnyChild = React.createClass({

  contextTypes: {
    triggerAlert: PropTypes.func.isRequired,
  },

  handleClick() {
    this.context.triggerAlert('Warning: you clicked!');
  },

  render() {
    return <button onClick={this.handleClick} />
  },

});

However, this ties into an imperative API. It also means that all your children components that spawn alerts must re-implement a bit of boilerplate.

You could also move everything that relate to Alert rendering into their own components, hiding your implementation under a simpler API:

const AlertRenderer = React.createClass({

  childContextTypes: {
    addAlert: PropTypes.func.isRequired,
  },

  getChildContext() {
    return {
      addAlert: this.addAlert,
    };
  },

  getInitialState() {
    return {
      alerts: [],
    };
  },

  addAlert(alert) {
    this.setState(({ alerts }) =>
      ({ alerts: alerts.concat([alert]) })
    );

    const remove = () => {
      this.setState(({ alerts }) =>
        ({ alerts: alerts.splice(alerts.indexOf(alert), 1) })
      );
    };

    const update = () => {
      this.setState(({ alerts }) =>
        ({ alerts: alerts.splice(alerts.indexOf(alert), 1, alert) })
      );
    };

    return { remove, update };
  },

  render() {
    return (
      <div>
        {this.props.children}
        {this.state.alerts.map(alert =>
          <this.props.component
            key={alert} // Or whatever uniquely describes an alert
            alert={alert}
          />
        )}
      </div>
    );
  },

});

const Alert = React.createClass({

  contextTypes: {
    addAlert: PropTypes.func.isRequired,
  },

  propTypes: {
    alert: PropTypes.any.isRequired,
  },

  componentWillMount() {
    const { update, remove } = this.context.addAlert(this.props.alert);
    this.updateAlert = update;
    this.removeAlert = remove;
  },

  componentWillUnmount() {
    this.removeAlert();
  },

  componentWillReceiveProps(nextProps) {
    this.updateAlert(nextProps.alert);
  },

  render() {
    return null;
  },

});

Your application's code then becomes:

const AnyChild = React.createClass({

  getInitialState() {
    return {
      alert: null,
    };
  },

  handleClick() {
    this.setState({
      alert: 'Warning: you clicked the wrong button!',
    });
  },

  render() {
    return (
      <div>
        <button onClick={this.handleClick}>

        </button>
        {this.state.alert &&
          <Alert alert={this.state.alert} />
        }
      </div>
    );
  },

});

const MainView = React.createClass({

  render() {
    return (
      <AlertRenderer component={MyAlertComponent}>
        {this.props.children}
      </AlertRenderer>
    );
  },

});

An issue with this particular approach is that AlertRenderer will re-render all of its children whenever an alert is updated. This can cause infinite recursion as AlertSub's componentWillReceiveProps lifecycle fires again. Note that this problem only appears if your components have mutable props and don't implement shouldComponentUpdate.

A simple way to fix this would be to create a conditional children renderer, that only updates when it detects that its children have changed.

The example code below creates such a conditional renderer for a single child. In order to conditionally render multiple children, you'd need to wrap then into an additional DOM component.

const ShouldSingleChildUpdate = React.createClass({

  shouldComponentUpdate(nextProps) {
    return nextProps.children !== this.props.children;
  },

  render() {
    return this.props.children;
  },

});

const AlertRenderer = React.createClass({

  /* ... */

  render() {
    return (
      <div>
        <ShouldSingleChildUpdate>
          {this.props.children}
        </ShouldSingleChildUpdate>
        {this.state.alerts.map(alert =>
          <this.props.component
            key={alert} // Or whatever uniquely describes an alert
            alert={alert}
          />
        )}
      </div>
    );
  },

});

Note that this approach was thought for modals of any kind, where more than one modals can be displayed on screen at any time. In your case, since you should only ever have one Alert component on screen at any time, you can simplify the AlertRenderer code by only storing a single alert in state.

Alexandre Kirszenberg
  • 33,587
  • 8
  • 84
  • 70
  • Thanks for your input! I appreciate the many different approaches and, although they were perfectly legitimate solutions and well explained, I felt like they were a bit too complex and/or with many drawbacks. That's just how React is I suppose. In addition, `context` seems like it might rotate out in the future, so I guess there's that as well. – Chris May 05 '16 at 13:48