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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<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>
<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>
<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":

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
);
ReactDOM.render( <React.StrictMode> <Provider store={store}> <App /> </Provider> </React.StrictMode>, document.getElementById('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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<div
onClick={() => alert('I was clicked! Even from outside my DOM which means event propagates properly')}>
<OutsideToastsComponent />
</div>
<div onClick={() => alert('I was clicked! Even from outside my DOM which means event propagates properly')}> <OutsideToastsComponent /> </div>
<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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
return ReactDOM.createPortal(
<>
<br />
<button onClick={onAddClick}>
Add toast
</button>
{toastList}
</>,
document.querySelector('#outside-root'),
);
return ReactDOM.createPortal( <> <br /> <button onClick={onAddClick}> Add toast </button> {toastList} </>, document.querySelector('#outside-root'), );
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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
export const ToastsContext = createContext({
// default values
toasts: [],
addToast: () => {},
removeToast: el => {},
toastsVisible: false,
});
export const ToastsContext = createContext({ // default values toasts: [], addToast: () => {}, removeToast: el => {}, toastsVisible: false, });
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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const [contextToastsVisible, setContextToastsVisible] = useState(false);
const [contextToasts, setContextToasts] = useState([]);
const [contextToastsVisible, setContextToastsVisible] = useState(false); const [contextToasts, setContextToasts] = useState([]);
const [contextToastsVisible, setContextToastsVisible] = useState(false);
const [contextToasts, setContextToasts] = useState([]);
Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
<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>
<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>
<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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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>
))
}
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> )) }
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ą

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
npx create-react-app my-app --template redux
npx create-react-app my-app --template redux
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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
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;
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;
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:

Plain text
Copy to clipboard
Open code in new window
EnlighterJS 3 Syntax Highlighter
const toasts = useSelector(state => state.toasts.list);
const toasts = useSelector(state => state.toasts.list);
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.