Home > database >  How to properly implement "subscription"-like fetches with React useEffect
How to properly implement "subscription"-like fetches with React useEffect

Time:04-24

I have a question about the "proper" (or most idiomatic) way to implement network fetch behavior in React based on a single changing property.

A simplified example of the functionality I'm building is below: I am looking to build a multi-page form that "auto-saves" a draft of form inputs as the user navigates back/forth between pages.

TL;DR - I thought useEffect hooks would be the right way to save a draft to the backend every time a url slug prop changes, but I'm running into issues, and wondering about suggestions for the "right" tool for this type of behavior.

Here is my attempt so far. My code is technically working how I want it to, but violates React's recommended hook dependency pattern (and breaks the exhaustive-deps ESLint rule).

import React from 'react';

const ALL_SLUGS = [
  'apple',
  'banana',
  'coconut',
];
function randomUrlSlug() {
  return ALL_SLUGS[Math.floor((Math.random() * ALL_SLUGS.length))];
}

// just resovles the same object passed in
const dummySaveDraftToBackend = (input) => {
  return new Promise((resolve, _reject) => {
    setTimeout(() => {
      resolve(input);
    }, 1000);
  });
};

export function App() {
  const [urlSlug, setUrlSlug] = React.useState(randomUrlSlug());

  return (
    <MyComponent urlSlug={urlSlug} setUrlSlug={setUrlSlug} />
  );
}

export function MyComponent({ urlSlug, setUrlSlug }) {
  const [loading, setLoading] = React.useState(false);
  const [complexState, setComplexState] = React.useState({ foo: 'bar', baz: 'wow', responseCount: 0 });

  // useCallback memoization is technically unnecessary as written here,
  // but if i follow the linter's advice (listing handleSave as a dependency of the useEffect below), it also suggests memoizing here.
  // However, complexState is also technically a dependency of this callback memo, which causes the fetch to trigger every time state changes.
  //
  // Similarly, moving all of this inside the effect hook, makes the hook dependent on `complexState`, which means the call to the backend happens every time a user changes input data.
  const handleSave = React.useCallback(() => {
    console.log('*** : start fetch');

    setLoading(true);

    dummySaveDraftToBackend(complexState).then((resp) => {
      console.log('fetch response: ', resp);

      // to keep this example simple, here we are just updating
      // a dummy "responseCount", but in the actual implementation,
      // I'm using a state reducer, and want to make some updates to form state based on error handling, backend validation, etc.
      setComplexState((s) => ({
        ...resp,
        responseCount: s.responseCount   1,
      }));
      setLoading(false);
    });
  }, [complexState]);

  // I know this triggers on mount and am aware of strategies to prevent that.
  // Just leaving that behavior as-is for the simplified example.
  React.useEffect(() => {
    if (urlSlug) {
      handleSave();
    }
  }, [urlSlug]); // <- React wants me to also include my memoized handleSave function here, whose reference changes every time state changes. If I include it, the fetch fires every time state changes.

  return (
    <div className="App">
      <h2>the current slug is:</h2>
      <h3>{urlSlug}</h3>

      <div>the current state is:</div>
      <pre>{JSON.stringify(complexState, null, 2)}</pre>

      <div>
        <h2>edit foo</h2>
        <input value={complexState.foo} onChange={(e) => setComplexState((s) => ({ ...s, foo: e.target.value }))} disabled={loading} />
      </div>

      <div>
        <h2>edit baz</h2>
        <input value={complexState.baz} onChange={(e) => setComplexState((s) => ({ ...s, baz: e.target.value }))} disabled={loading} />
      </div>

      <div>
        <button
          type="button"
          onClick={() => setUrlSlug(randomUrlSlug())}
          disabled={loading}
        >
          click to change to a random URL slug
        </button>
      </div>
    </div>
  );
}

As written, this does what I want it to do, but I had to omit my handleSave function as a dependency of my useEffect to get it to work. If I list handleSave as a dependency, the hook then relies on complexState, which changes (and thus fires the effect) every time the user modifies input.

I'm concerned about violating React's guidance for not including dependencies. As-is, I would also need to manually prevent the effect from running on mount. But because of the warning, I'm wondering if I should not use a useEffect pattern for this, and if there's a better way.

I believe I could also manually read/write state to a ref to accomplish this, but haven't explored that in much depth yet. I have also explored using event listeners on browser popstate events, which is leading me down another rabbit hole of bugginess.

I know that useEffect hooks are typically intended to be used for side effects based on event behavior (e.g. trigger a fetch on a button click). In my use case however, I can't rely solely on user interactions with elements on the page, since I also want to trigger autosave behavior when the user navigates with their browser back/forward controls (I'm using react-router; current version of react-router has hooks for this behavior, but I'm unfortunately locked in to an old version for the project I'm working on).

Through this process, I realized my understanding might be a bit off on proper usage of hook dependencies, and would love some clarity on what the pitfalls of this current implementation could be. Specifically:

  1. In my snippet above, could somebody clarify to me why ignoring the ESLint rule could be "bad"? Specifically, why might ignoring a dependency on some complex state can be problematic, especially since I dont want to trigger an effect when that state changes?

  2. Is there a better pattern I could use here - instead of relying on a useEffect hook - that is more idiomatic? I basically want to implement a subscriber pattern, i.e. "do something every time a prop changes, and ONLY when that prop changes"

CodePudding user response:

If all the "state" that is updated after saving it to backend is only a call count, declare this as a separate chunk of state. This eliminates creating a render loop on complexState.

Use a React ref to cache the current state value and reference the ref in the useEffect callback. This is to separate the concerns of updating the local form state from the action of saving it in the backend on a different schedule.

Ideally each useState hook's "state" should be closely related properties/values. The complexState appears to be your form data that is being saved in the backend while the responseCount is completely unrelated to the actual form data, but rather it is related to how many times the data has been synchronized.

Example:

export function MyComponent({ urlSlug, setUrlSlug }) {
  const [loading, setLoading] = React.useState(false);
  const [complexState, setComplexState] = React.useState({ foo: 'bar', baz: 'wow' });
  const [responseCount, setResponseCount] = React.useState(0);

  const complexStateRef = React.useRef();

  React.useEffect(() => {
    complexStateRef.current = complexState;
  }, [complexState]);
  
  React.useEffect(() => {
    const handleSave = async (complexState) => {
      console.log('*** : start fetch');

      setLoading(true);
      try {
        const resp = await dummySaveDraftToBackend(complexState);
        console.log('fetch response: ', resp);

        setResponseCount(count => count   1);
      } catch(error) {
        // handle any rejected Promises, errors, etc...
      } finally {
        setLoading(false);
      }
    };
 
    if (urlSlug) {
      handleSave(complexStateRef.current);
    }
  }, [urlSlug]);

  return (
    ...
  );
}

CodePudding user response:

This feels like a move in the wrong direction (towards more complexity), but introducing an additional state to determine if the urlSlug has changed seems to work.

export function MyComponent({ urlSlug, setUrlSlug }) {
  const [slug, setSlug] = React.useState(urlSlug);
  const [loading, setLoading] = React.useState(false);
  const [complexState, setComplexState] = React.useState({ foo: 'bar', baz: 'wow', responseCount: 0 });

  const handleSave = React.useCallback(() => {
    if (urlSlug === slug) return        // only when slug changes and not on mount

    console.log('*** : start fetch');
    setLoading(true);

    dummyFetch(complexState).then((resp) => {
      console.log('fetch response: ', resp);

      setComplexState((s) => ({
        ...resp,
        responseCount: s.responseCount   1,
      }));
      setLoading(false);
    });
  }, [complexState, urlSlug, slug]);

  React.useEffect(() => {
    if (urlSlug) {
      handleSave();
      setSlug(urlSlug)
    }
  }, [urlSlug, handleSave]); 

Or move handleSave inside the useEffect (with additional slug check)

Updated with better semantics

export function MyComponent({ urlSlug, setUrlSlug }) {

  const [autoSave, setAutoSave] = React.useState(false);  // false for not on mount
  React.useEffect(() => {
    setAutoSave(true)
  }, [urlSlug])

  const [loading, setLoading] = React.useState(false);
  const [complexState, setComplexState] = React.useState({ foo: 'bar', baz: 'wow', responseCount: 0 });

  React.useEffect(() => {

    const handleSave = () => {
      if(!autoSave) return 
  
      console.log('*** : start fetch');
      setLoading(true);
  
      dummyFetch(complexState).then((resp) => {
        console.log('fetch response: ', resp);
  
        setComplexState((s) => ({
          ...resp,
          responseCount: s.responseCount   1,
        }));
        setLoading(false);
      });
    }
    
    if (urlSlug) {
      handleSave();
      setAutoSave(false)
    }
  }, [autoSave, complexState]); 
  • Related