Skip to content

The incompatibility of different Toaster APIs (hooks vs singleton) #15

Open
@icekimi23

Description

@icekimi23

Objective

In the gravity-ui library, there are several ways to invoke a toast (a notification window at the edge of the screen): through the useToaster hook or, where hooks are not applicable, through the Toaster singleton.

However, when using the singleton approach, a problem may arise where components rendered within such a toast do not have access to various application providers, because these toasts are mounted in a different root.

Using both approaches in a single project can lead to issues with toasts overlapping each other, as each method has its own stack of toasts. (An example is shown in the video.)

Solution Proposal

Create a universal API with methods that do not conflict with each other. This could be implemented similar to Redux, where a singleton is passed into a provider and can also be used externally.

Rough implementation

Click to expand
import React, { createContext, useContext, useState, useMemo, useEffect } from "react";

export const ToasterContext = createContext(null);
ToasterContext.displayName = "ToasterContext";

export const ToasterProvider = ({ toaster, children }) => {
  const [toasts, setToasts] = useState([]);

  useEffect(() => {
    const updateToasts = (toasts) => {
      setToasts((prev) => [...toasts]);
    };

    toaster.on(updateToasts);

    return () => {
      toaster.off(updateToasts);
    };
  }, [toaster]);

  return (
    <ToasterContext.Provider value={toaster}>
      <>
        {children}
        <div>
          {toasts.map((toast) => (
            <div>{toast}</div>
          ))}
        </div>
      </>
    </ToasterContext.Provider>
  );
};

export const useToaster = () => {
  const toaster = useContext(ToasterContext);

  return useMemo(() => toaster, [toaster]);
};

class Toaster {
  constructor() {
    this.toasts = [];
    this.listeners = [];
  }

  add(toast) {
    this.toasts.push(toast);

    for (const listener of this.listeners) {
      listener(this.toasts);
    }
  }

  on(fn) {
    this.listeners.push(fn);
  }

  off(fn) {
    this.listeners = this.listeners.filter((listener) => listener !== fn);
  }
}

const toaster = new Toaster();

function SomeComponent() {
  const toasterFromHook = useToaster();

  const handleClick = () => {
    toasterFromHook.add("a new fresh toast");
  };

  return <button onClick={handleClick}>Add toast</button>;
}

setTimeout(() => {
  toaster.add('toast from setTimeout');  
}, 3000);

export default function App() {
  return (
    <div className="App">
      <ToasterProvider toaster={toaster}>
        <SomeComponent />
      </ToasterProvider>
    </div>
  );
}

The main idea is to create an object that maintains the state of toasts and allows subscribing to its changes. Essentially, it's the same concept as redux + react-redux. This way, the object is created once in the service and then used as needed.

This is my initial thought, but if someone suggests something simpler, I'd be happy to take a look :)

Definition of done

A pull request is made with a universal, non-conflicting API.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions