Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Optimistic rendering & useEffect

Tags:

TL'DR: Is there a way to pessimistically operate state changes while using useEffect for API calls?

Lets say you wrote a component that displays a paginated / sorted grid of data and you wrote a useEffect for that component similar to this fake example:

useEffect(() => {
  fetch(`https://some-random-api.com/data/page/{paging}/sort/{sorting}`)
    .then(response => response.json())
    .then(data => setState({
      data
    }));
}, [paging, sorting]);

So, if I'm not mistaken this will fetch when the paging or sorting states/props (whatev) are updated. This means that it is optimistically rendering the new sorting and paging states before the data is loaded. Basically the app tells the user "I'm on the requested page with the requested sort but I'm still loading it". In other words, part of the app state (the paging and sorting) says "I'm done" the other part of the state (the loading) says "I'm working on it".

By contrast if I were to pessimistically update states I would only set a loading state to true before the fetch and then when a response is received (and only then) set paging, sorting (& data & loading). So the app tells the user "I heard you and I'm now working on it" and then when a successful response is received the app says "here is the requested data and updated context (updated page / sort status)".

The other issue is if the request fails. In the optimisitic/useEffect approach I now need to revert the paging & sorting states that were updated before attempting to load the data for those state changes. That complexity grows with the number of dependencies. Plus, here is the kicker (again, if I'm not mistaken) reverting those states will result in another fetch (which could fail again). In the pessimistic approach you just set loading = false when an error occurs and there's no other state changes to be made because paging & sorting are only updated when the data is successfully received. The data stays synchronized in a pessimistic approach (not optimistic) which is why I think it's ironic when blogs and videos say "think in terms of synchronization" when using useEffect.

One more thing, say an API called from within useEffect (in some other example) was actually changing something on the server-side. In that case when using useEffect and applying it's optimistic approach is just lying to the user. The app is saying "Ok, I updated that". Maybe not. If not (if there was an error) hopefully there's state update to revert the state change that lied to the user and somehow avoids an additional API call when reverting that state. And hopefully the user saw that state change back & hopefully there was a message... Even then, is that a good UX?

I'm not saying optimistic rendering is always bad. I am saying "I like being pessimistic most of the time. How do I do that while using useEffect?" :)

It seems useEffect is being used and promoted for most side effects of apps (A LOT). I'm not finding it useful very often. I'm a pessimist and want to be enabled with an alternative.

like image 813
Stewart Anderson Avatar asked Aug 14 '19 22:08

Stewart Anderson


1 Answers

The way you're describing it, it sounds like the rest of your component is structured like

[state, setState] => useState(initialState)
[paging, setPaging] => useState(initialPaging)
[sorting, setSorting] => useState(initialSort)

useEffect(() => {
  fetch(`https://some-random-api.com/data/page/{paging}/sort/{sorting}`)
    .then(response => response.json())
    .then(data => setState({
      data
    }));
}, [paging, sorting]);

return <div>
    <span>Current page is {paging}</span>
    <Button onClick={() => setPaging(page => page + 1)}>Nav Next</Button>
    <Button onClick={() => setPaging(page => page - 1)}>Nav Prev</Button>
    // ... and so on and so forth
</div>

and when you click on Nav Next or Prev, you don't want to paging updated until after the effect it triggers is resolved. If the fetch fails, you don't want the user to see the paging tick up and then down from whatever cleanup you need to do.

To achieve this, couldn't you delay setting the "forward facing" values by having intermediary "hopeful" or "pending" state values? For example, would this work?

[state, setState] => useState(initialState)

// Component state values
[paging, setPaging] => useState(initialPaging)
[sorting, setSorting] => useState(initialSort)

// "Optimist's" state values
[pendingPaging, setPendingPaging] => useState(paging)
[pendingSorting, setPendingSorting] => useState(sorting)

useEffect(() => {
  // checks if current state values differ from pending,
  // meaning app wants to do something
  // If pending and "actual" values match, do nothing
  if( paging !== pendingPaging
    || sorting !== pendingSorting
  ){
      fetch(`https://some-random-api.com/data/page/{paging}/sort/{sorting}`)
        .then(response => response.json())
        .then(data => {
          // things worked, updated component state to match pending
          setPaging(pendingPaging);
          setSorting(pendingSorting);
          setState({data});
        })
        .catch(err => {
          // ugh, I knew things would fail. Let me reset my hopes
          setPendingPaging(paging);
          setpendingSorting(sorting);
          setState({data});
        });
  }
}, [pendingPaging, pendingSorting, paging, sorting, setState]);

return <div>

    // show user "actual" page number
    <span>Current page is {paging}</span>

    // but update only pending values in response to events
    <Button onClick={() => setPendingPaging(page + 1)}>Nav Next</Button>
    <Button onClick={() => setPendingPaging(page - 1)}>Nav Prev</Button>

    // ... and so on and so forth
</div>

This way you set up your hoped for state, do something, and then set your state and expectations to match. In case of success, the state that's displayed updates to match the optimistic values, and in case of failure the pending values are set back to reality.

The biggest issue I see is the fact that several setState calls are made simultaneously when the async call resolves. React should batch these calls, but you might want to look up other ways of handling this in a single call.

Either way, pending and actual values are equal, so while useEffect will trigger, it won't run another fetch.

EDIT 1 Thanks for the positive feedback, glad you like the concept. I agree the complexity could get unwieldy, but I think you could set up a factory class to manage things more clearly.

For example, you could use a makePessimistic class that ultimately outputs the "actual" state, the "hopeful" state, and at least one usePessimisticEffect callback. That class could look like:

class pessimistFactory {
  constructor(){
    this.state = {};
    this.effects = [];
  }
  // util from https://dzone.com/articles/how-to-capitalize-the-first-letter-of-a-string-in
  ucfirst = (string) => string.charAt(0).toUpperCase() + string.slice(1);

  registerState(param, initialVal){
    this.state[param] = initialVal;
  }

  makePessimisticEffect = (callback, dependencies) => useEffect(() => {
    async function handlePessimistically(){
      try {
        const result = await callback(...dependencies);
        // update theState with result
        setState({...hopefulState, result})
      }catch(err){
        // reset hopefulState and handle error
        setHopefulState({...theState})
      }
    }

    // Do something if some action pending
    if(/* pending and current state not the same */) handlePessimistically();

  }, [callback, dependencies]);

  registerEffect(callback, dependencies = []){
    this.effects.push(
      this.makePessimisticEffect(callback, dependencies)
    );
  }

  makeHooks = () => {
    const initialState = useState(this.state);
    return {
      theState: initialState,
      hopefulState: initialState,
      effects: this.effects
    }
  }
}

And in practice:

// Make factory instance
const factory = new pessimistFactory();

// Register state and effects
factory.registerState('paging', initialPaging)
factory.registerState('sorting', initialSorting)
factory.registerEffect('fetchData', () => fetch(`https://some-random-api.com/data/page/{paging}/sort/{sorting}`))

// Make the hooks
const {theState, hopefulState, effects} = factory.makeHooks();

// Implement hooks in component
const SomeComponent = () => {
  const [state, setState] = theState();
  const [pendingState, setPendingState] = hopefulState();

  effects.forEach(useEffect => useEffect());

  return <div>displayed stuff</div>
}

The trickiest part would be setting up the makePessimisticEffect effect to read and handle the values and callbacks created by generated setStates, which I realize if completely broken as I've written it.

I don't have time right now to actually figure out the details, but I think it's got to be possible with the new hooks available like useCallback.

I do like this idea, though, so I'll try to figure this out later when I do have the time. If you make a working factory class before that, or if someone has a better solution that's different altogether, I'd be very interested to see it.

like image 88
Mickey Avatar answered Nov 15 '22 07:11

Mickey