Creating A React Component That Can Be Shown With A Function Call (like react-toastify

Viewed 532

Typically, when creating a reusable React component that we want to conditionally render, we'll either give it a prop to tell it whether or not to render itself:

function TheComponent(props) {
    return(
        props.isVisible?<div>...</div>:null
    );
}

Or just render the whole component conditionally, from the outside:

function App() {
    //...
    return (
            isVisible ? <TheComponent /> : null
        );
}

Alternatively, if we want to make a component that we can show/hide from anywhere in our application - like a toast notification - we could wrap in a provider & make a custom hook to access its context; this would let us show/hide it from anywhere inside the provider, just by calling a function:

const App = () => (
  <ToastProvider>
    <OtherStuff />
  </ToastProvider>
);

const OtherStuff = () => {
  const { showToast } = useToast();
  showToast();
  return ...;
};

However, there's a really cool package, react-toastify, that I can't seem to wrap my head around how it's implemented. All you have to do is drop a <ToastContainer /> somewhere in your app, then from anywhere else, you can:

import { toast } from "react-toastify";
toast.info("this will show the component with a message");

Since this function can be called outside of a provider, I don't really understand how it's controlling the state of the component elsewhere in the tree. I've tried looking into its code, but as a React beginner, it's a bit over my head. I love the idea of a totally self-contained component that you can just stick somewhere in your app, and invoke by calling a function from anywhere. No Provider/wrapper or anything: just a function call, and out it pops.

Can someone help shed some light on how, fundamentally, a component like this could work? How can a function outside of a provider be controlling state inside another component?

1 Answers

Glancing at the react-toastify code you can see it’s using an event emitter pattern. The <ToastContainer /> listens for events that get dispatched (or emitted) when you call toast.info. When it gets an event it updates its internal state (presumably) to show the message.


TLDR: They're communicating indirectly through the eventManager, which exposes methods for 1) dispatching events and 2) registering listeners for those events.


It's similar to the way an onclick handler works in the DOM.

Here's a very rudimentary implementation of the basic pattern: It just appends a div to the document each time the button is clicked. (This isn't React- or toastify-specific. But it demonstrates the core idea.)

Notice that the button's click handler doesn't know anything about what happens. It doesn't append the div. It just emits an event via the EventBus instance described below.

The EventBus class provides an on method to register a listener. These are often called addEventListener or addListener, or they have an event-specific name like onClick, onChange, etc., but they all do the same basic thing: register a function to be invoked in response to an event. (This class is essentially a dumber implementation of react-toastify's eventManager.)

The on method adds the provided handler to an internal array. Then, when an event is fired (via emit) it just iterates over the array invoking each one and passing in the event information.

const container = document.getElementById('demo');
const button = document.querySelector('button');

class EventBus {
  handlers = [];
  
  on (handler) {
    this.handlers.push(handler);
  }
  
  emit (event) {
    this.handlers.forEach(h => h(event));
  }
}

const emitter = new EventBus();

emitter.on((event) => {
  container.innerHTML += `<div>${event}</div>`;
})

button.addEventListener('click', () => emitter.emit('Button Clicked'));
<button>Emit</button>
<div id="demo"></div>

With this setup you can add additional listeners to do other things without having to know where the event originates (the button click). The demo below is the same as above except it adds an additional handler to toggle "dark" mode.

Again, notice that the button doesn't know about dark mode, and the dark mode handler doesn't know about the button, and neither of them know about the div being appended. They're completely decoupled.

This is basically how the ToastContainer works with toast.info.

const container = document.getElementById('demo');
const button = document.querySelector('button');

class EventBus {
  handlers = [];
  
  on (handler) {
    this.handlers.push(handler);
  }
  
  emit (event) {
    this.handlers.forEach(h => h(event));
  }
}

const emitter = new EventBus();

emitter.on((event) => {
  container.innerHTML += `<div>${event}</div>`;
})

button.addEventListener('click', () => emitter.emit('Button Clicked'));

// add an additional handler
emitter.on(event => demo.classList.toggle('dark'));
.dark {
  background: black;
  color: white;
}
<button>Emit</button>
<div id="demo"></div>

Related