React Portals i podejścia alternatywne

Spis treści

  1. Wprowadzenie
  2. React Portals
  3. Alternatywy dla React Portals
    1. React Context
    2. React Redux
  4. Kod przykładu na Github
  5. Klikalny przykład na Github Pages
  6. Podsumowanie

Wprowadzenie

Portale w React są funkcjonalnością, na którą natkniesz się prawdopodobnie na samym końcu – po przekopaniu całego internetu w poszukiwaniu rozwiązania. Renderowanie zawartości w innym miejscu niż logicznie element należy wydaje się nie mieć sensu – pomaga jednak rozwiązać problemy z z-index czy overflow na jakie możesz natknąć się przy próbach wyświetlenia treści. Choć Portal niekoniecznie będzie pierwszym wyborem – często może okazać się znacznie szybszym rozwiązaniem niż refaktor lub inne bardziej oczywiste opcje – poznajmy więc jak one działają.

React Portals

Portale pozwalają wyświetlić element w podanym rodzicu, który może być zlokalizowany w dowolnym miejscu. Aby móc to wykorzystać stwórzmy w pliku index.html element ulokowany poza rootem głównej aplikacji Reacta. Dla przypomnienia, domyślnie aplikacja CRA zawiera index.html z div z id="root", w którym wyrenderuje się aplikacja. Dodajmy element poza głównym rootem oraz kolory, które pozwolą rozróżnić oba elementy:

<body>
  <div id="root" style="border: 1px solid red; padding: 20px"></div>
  <div style="border: 1px solid blue; padding: 20px">
    <h2>
      Outside React app
    </h2>
    <div id="outside-root">
      Something inside, but won't be overriden
    </div>
  </div>
</body>

Przypomnijmy, że React użyje elementu z id="root":

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')
);

W kolejnym kroku stworzymy OutsideToastsComponent zawierający “toasty”, komponent umieścimy wewnątrz div na którym przypniemy wyświetlanie alertu na kliknięcie – by pokazać, że propagacja eventów w górę działa poprawnie mimo że elementy nie będą w drzewie DOM tego elementu:

<div
  onClick={() => alert('I was clicked! Even from outside my DOM which means event propagates properly')}>
  <OutsideToastsComponent />
</div>

Sam komponent będzie zawierał standardowe elementy stąd nie będziemy całego tu wklejać – kod na Github i przykład klikalny na Github Pages będą dostępne – warto natomiast zwrócić uwagę na to, co jest zwracane:

return ReactDOM.createPortal(
  <>
    <br />
    <button onClick={onAddClick}>
      Add toast
    </button>
    {toastList}
  </>,
  document.querySelector('#outside-root'),
);

To, co w normalnym kodzie byłoby zwrócone bezpośrednio, jest przekazane jako pierwszy parametr do funkcji createPortal. Drugim parametrem jest miejsce gdzie elementy mają zostać wyrenderowane.

I faktycznie, elementy renderują się poza głównym drzewem aplikacji czyli div z id="root":

Natomiast jeśli chodzi o podgląd drzewa komponentów React, komponent nasz pokazuje się w drzewie aplikacji:

Alternatywy dla React Portals

Alternatywnym podejściem do powyższego jest pozostawienie toastów w aplikacji głównej w normalnym flow Reactowym, ale umieszczenie komponentu z nimi niezbyt głęboko w drzewie komponentów.

Z pomocą w implementacji takiego podejścia pomoże nam globalne zarządzanie stanem. Rozważymy tutaj dwie opcje:

  1. React Context
  2. React Redux

React Context

React Context pokrótce przedstawiłem w jednym ze swoich postów:

Tutaj wystawimy przykładowy Context:

export const ToastsContext = createContext({
  // default values
  toasts: [],
  addToast: () => {},
  removeToast: el => {},
  toastsVisible: false,
});

Nadamy kontekstowi wartości, które przechowywane będą w stanie głównego komponentu:

const [contextToastsVisible, setContextToastsVisible] = useState(false);
const [contextToasts, setContextToasts] = useState([]);
<ToastsContext.Provider value={{
  toasts: contextToasts,
  addToast: () => setContextToasts(toasts => [...toasts, new Date().getTime()]),
  toastsVisible: contextToastsVisible,
  removeToast: (id) => setContextToasts(toasts => toasts.filter(toast => toast !== id))
}}>
  <ContextToastsComponent />
</ToastsContext.Provider>

Sam komponent zaś składa się z 2 mniejszych komponentów, które wartości odczytają bezpośrednio z kontekstu:

export const ContextToastsComponent = () => (
  <>
    <br />
    <AddToast />
    <ToastList />
  </>
)

const useToastsContext = () => useContext(ToastsContext);

const AddToast = () => {
  const {addToast} = useToastsContext();

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

const ToastList = () => {
  const {toasts, toastsVisible, removeToast} = useToastsContext();

  if (!toastsVisible) {
    return null;
  }
  return toasts.map(toast => (
    <div key={toast}>
      Toast with id: {toast}
      <br />
      <button onClick={() => removeToast(toast)}>Remove me</button>
    </div>
  ))
}

React Redux

Wygenerowany przez CRA projekt komendą

npx create-react-app my-app --template redux

Będzie domyślnie używał biblioteki Redux Toolkit. Biblioteka ta pochodzi od twórców Reacta i znacząco usprawnia pracę z nim. Jeśli chcesz poznać Redux Toolkit lepiej, kliknij w poniższy obrazek:

Samo pisane kodu rozpocznijmy od dodania slice, który będzie obsługiwał stan:

const initialState = {
  toastsVisible: false,
  list: [],
};

export const toastsSlice = createSlice({
  name: 'toasts',
  initialState,
  reducers: {
    toggleToasts: state => {
      state.toastsVisible = !state.toastsVisible;
    },
    addToast: state => {
      state.list.push(new Date().getTime());
    },
    removeToast: (state, {payload}) => {
      state.list = state.list.filter(toast => toast !== payload);
    },
  },
});

export const { toggleToasts, addToast, removeToast } = toastsSlice.actions;

export const toastsReducer = toastsSlice.reducer;

Po czym dane możemy odczytywać w komponencie:

const toasts = useSelector(state => state.toasts.list);

Sam kod komponentu, który jest prosty – zawiera dispatchowanie innych akcji i wyświetlenie listy – dostępny będzie na Github.

Zanim się rozstaniemy – na potrzeby tego przykładu użyłem hooka useSelector – w pracy jednak nie przechodzimy na razie z connect na useSelector mimo, ze wersje bibliotek pozwalają. Dlaczego? Ponieważ niesie to ze sobą kilka zagrożeń, które opisałem w artykule:

Kod przykładu na Github

https://github.com/radek-anuszewski/react-portals

Klikalny przykład na Github Pages

https://radek-anuszewski.github.io/react-portals/

Podsumowanie

Choć Portale nie wydają się być rozwiązaniem pierwszego wyboru, warto zdawać sobie sprawę z ich istnienia. Choć do problemów które rozwiązują można podejść inaczej – na przykład za pomocą globalnego stanu – mogą pomóc w sytuacji, gdy inne rozwiązania będą kosztowne.