How to access state when component unmount with React Hooks?
Asked Answered
B

4

27

With regular React it's possible to have something like this:

class NoteEditor extends React.PureComponent {

    constructor() {
        super();

        this.state = {
            noteId: 123,
        };
    }

    componentWillUnmount() {
        logger('This note has been closed: ' + this.state.noteId);
    }

    // ... more code to load and save note

}

In React Hooks, one could write this:

function NoteEditor {
    const [noteId, setNoteId] = useState(123);

    useEffect(() => {
        return () => {
            logger('This note has been closed: ' + noteId); // bug!!
        }
    }, [])

    return '...';
}

What's returned from useEffect will be executed only once before the component unmount, however the state (as in the code above) would be stale.

A solution would be to pass noteId as a dependency, but then the effect would run on every render, not just once. Or to use a reference, but this is very hard to maintain.

So is there any recommended pattern to implement this using React Hook?

With regular React, it's possible to access the state from anywhere in the component, but with hooks it seems there are only convoluted ways, each with serious drawbacks, or maybe I'm just missing something.

Any suggestion?

Beanstalk answered 28/2, 2020 at 17:39 Comment(7)
"convoluted ways": hooks tend to be significantly simpler than their class based counterparts. "serious drawbacks": I'd be very interested to know what these would be. "but this is very hard to maintain": why is a ref hard to maintain? Update the ref when nodeId changes, and use the current useEffect to read it?Sibyls
Does this answer your question? componentWillUnmount with React useEffectHirsch
"Update the ref when nodeId changes" - which means there are two variables instead of one to update every time the nodeId changes. Sure that can be done but that's not simpler. In fact even the React Hooks doc recommends against using ref (although they don't say what to use instead).Beanstalk
Class components do sometimes make more sense. This might be one of those cases. Or perhaps relying on a component being unmounted to track whether something has been closed might not be the best way?Sibyls
"but then the effect would run on every render" - should read "but then the effect would run on change of noteId".Reaves
@evolutionbox, perhaps a class component would indeed make more sense then. Also maybe I'm trying to replicate old React behaviour with hooks, instead of using proper React hooks patterns, if any.Beanstalk
It's so hard to realize "the returned function fixes the initial value for its use" when I have the mindset of "it's a state so it'll always be up-to-date". This feels like a bug of React. A useUnmountEffect who can update the state used in it would be nice.Truda
R
9

useState() is a specialized form of useReducer(), so you can substitute a full reducer to get the current state and get around the closure problem.

NoteEditor

import React, { useEffect, useReducer } from "react";

function reducer(state, action) {
  switch (action.type) {
    case "set":
      return action.payload;
    case "unMount":
      console.log("This note has been closed: " + state); // This note has been closed: 201
      break;
    default:
      throw new Error();
  }
}

function NoteEditor({ initialNoteId }) {
  const [noteId, dispatch] = useReducer(reducer, initialNoteId);

  useEffect(function logBeforeUnMount() {
    return () => dispatch({ type: "unMount" });
  }, []);

  useEffect(function changeIdSideEffect() {
    setTimeout(() => {
      dispatch({ type: "set", payload: noteId + 1 });
    }, 1000);
  }, []);

  return <div>{noteId}</div>;
}
export default NoteEditor;

App

import React, { useState, useEffect } from "react";
import "./styles.css";
import NoteEditor from "./note-editor";

export default function App() {
  const [notes, setNotes] = useState([100, 200, 300]);

  useEffect(function removeNote() {
    setTimeout(() => {
      setNotes([100, 300]);
    }, 2000);
  }, []);

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      {notes.map(note => (
        <NoteEditor key={`Note${note}`} initialNoteId={note} />
      ))}
    </div>
  );
}
Reaves answered 28/2, 2020 at 23:28 Comment(2)
This is technically doesn't give you access to the state in the use effect cleanup function. A useRef() hook's current property should refer to state, and then you can access state directly in the cleanup.Astronavigation
See github.com/facebook/react/issues/14285 ? dispatch() is not getting called at unmount (for me anyway) because reducers are not expected to have side effects.Butcher
E
31

useRef() to the rescue.

Since the ref is mutable and exists for the lifetime of the component, we can use it to store the current value whenever it is updated and still access that value in the cleanup function of our useEffect via the ref's value .current property.

So there will be an additional useEffect() to keep the ref's value updated whenever the state changes.

Sample snippet

const [value, setValue] = useState();
const valueRef = useRef();

useEffect(() => {
  valueRef.current = value;
}, [value]);

useEffect(() => {
  return function cleanup() {
    console.log(valueRef.current);
  };
}, []);

Thanks to the author of https://www.timveletta.com/blog/2020-07-14-accessing-react-state-in-your-component-cleanup-with-hooks/. Please refer this link for deep diving.

Encipher answered 30/4, 2021 at 11:45 Comment(3)
Refs can be used of course, but instead of having one value, you now have two that you need to keep in sync.Beanstalk
Lifesaver, thanks!Talie
@Beanstalk use a custom hook that would update value for both state and ref github.com/Aminadav/react-useStateRef/blob/master/index.tsRespite
R
9

useState() is a specialized form of useReducer(), so you can substitute a full reducer to get the current state and get around the closure problem.

NoteEditor

import React, { useEffect, useReducer } from "react";

function reducer(state, action) {
  switch (action.type) {
    case "set":
      return action.payload;
    case "unMount":
      console.log("This note has been closed: " + state); // This note has been closed: 201
      break;
    default:
      throw new Error();
  }
}

function NoteEditor({ initialNoteId }) {
  const [noteId, dispatch] = useReducer(reducer, initialNoteId);

  useEffect(function logBeforeUnMount() {
    return () => dispatch({ type: "unMount" });
  }, []);

  useEffect(function changeIdSideEffect() {
    setTimeout(() => {
      dispatch({ type: "set", payload: noteId + 1 });
    }, 1000);
  }, []);

  return <div>{noteId}</div>;
}
export default NoteEditor;

App

import React, { useState, useEffect } from "react";
import "./styles.css";
import NoteEditor from "./note-editor";

export default function App() {
  const [notes, setNotes] = useState([100, 200, 300]);

  useEffect(function removeNote() {
    setTimeout(() => {
      setNotes([100, 300]);
    }, 2000);
  }, []);

  return (
    <div className="App">
      <h1>Hello CodeSandbox</h1>
      <h2>Start editing to see some magic happen!</h2>
      {notes.map(note => (
        <NoteEditor key={`Note${note}`} initialNoteId={note} />
      ))}
    </div>
  );
}
Reaves answered 28/2, 2020 at 23:28 Comment(2)
This is technically doesn't give you access to the state in the use effect cleanup function. A useRef() hook's current property should refer to state, and then you can access state directly in the cleanup.Astronavigation
See github.com/facebook/react/issues/14285 ? dispatch() is not getting called at unmount (for me anyway) because reducers are not expected to have side effects.Butcher
P
6

I wanted to chime in with an answer for this in case someone else runs into this. If you need more than one value in your useEffect unmount function, it's important to make sure that the correct dependencies are being used. So the accepted answer works fine because it's just one dependency, but start including more dependencies, and it gets complicated. The amount of useRef's you need get out of hand. So instead, what you can do is a useRef that is the unmount function itself, and call that when you unmount the component:

import React, { useRef, useState, useContext, useCallback, useEffect } from 'react';
import { Heading, Input } from '../components';
import { AppContext } from '../../AppContext';

export const TitleSection: React.FC = ({ thing }) => {
  const { updateThing } = useContext(AppContext);
  const [name, setName] = useState(thing.name);
  const timer = useRef(null);
  const onUnmount = useRef();

  const handleChangeName = useCallback((event) => {
    setName(event.target.value);

    timer.current !== null && clearTimeout(timer.current);
    timer.current = setTimeout(() => {
      updateThing({
        name: name || ''
      });
      timer.current = null;
    }, 1000);
  }, [name, updateThing]);

  useEffect(() => {
    onUnmount.current = () => {
      if (thing?.name !== name) {
        timer.current !== null && clearTimeout(timer.current);
        updateThing({
          name: name || '',
        });
        timer.current = null;
      }
    };
  }, [thing?.name, name, updateThing]);

  useEffect(() => {
    return () => {
      onUnmount.current?.();
    };
  }, []);

  return (
    <>
      <Heading as="h1" fontSize="md" style={{ marginBottom: 5 }}>
        Name
      </Heading>
      <Input
        placeholder='Grab eggs from the store...'
        value={name}
        onChange={handleChangeName}
        variant='white'
      />
    </>
  );
};
Papillose answered 15/12, 2021 at 18:16 Comment(0)
I
0

for the use case of multiple items in the ref, you can just make the ref an object and add whatever key values you want to it:

const [value1, setValue1] = useState();
const [value2, setValue2] = useState();
const valueRef = useRef();

useEffect(() => {
  valueRef.current = { value1, value2 };
}, [value1, value2]);

useEffect(() => {
  return function cleanup() {
    console.log(`retained both ${valueRef.current.value1} and ${valueRef.current.value2}`);
  };
}, []);
Igniter answered 23/2, 2023 at 15:53 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.