Skip to content

Subtle caveat with async methods #184

Open
@k1w1

Description

@k1w1

React Easy State version: 6.3.0
Platform: browser

There is a subtle caveat when state is modified after an async function, or in any situation where the code is called asynchronously outside a React render cycle.

If a reference to new state is held across an async boundary then the new state is not observed and changes to it do no trigger a re-render.

Here is a code snippet that shows the problem. The key is that a local variable counter is used after the async call. If it is used before the async call it works correctly, or if counterStore is used after the call to get a reference to the value from the store again then it also works correctly.

import React from 'react';
import { store, view } from '@risingstack/react-easy-state';

const counterStore = store({
  counters: []
});

async function longFunction() {}

export default view(() => {
  const addCounter = async () => {
    counterStore.counters.push({value: 0});
    const counter = counterStore.counters[counterStore.counters.length-1];

    await longFunction();

    counter.value++;
    console.log("No re-render here!")
  }
  const incCounter = () => {
    counterStore.counters[counterStore.counters.length-1].value++;
  }
  return (
    <>
      <button onClick={() => addCounter()}>Add counter</button>
      <button onClick={() => incCounter()}>Increment counter</button>
      <ul>
        {counterStore.counters.map((counter, index) => (
          <li key={index}>{counter.value}</li>
        ))}
      </ul>
    </>
  );
});

It seems like you could just say "always reference the store when updating a value", but that is very hard to do, especially with complex code. The same above was created by removing all of the complexity and nested function calls that was otherwise hiding the problem.

This is disappointing because it undermines the awesome promise of react-easy-state. I think it is worth trying to solve so code like this can work transparently.

Theory of the problem

I think the problem exists because of this sequence:

  1. addCounter is called from an event handler, so changes to the state are proxied (observed), but the new values themselves are not wrapped in a proxy.
  2. This means that counter is not a proxied variable, even though it appears to have been correctly read from the store.
  3. Without the await function it works fine because the re-render is triggered when the element is added to the counterStore.counters array and the render doesn't happen until after the function exits (because React is batching the renders).

Second variation on problem

The same problem can be triggered using setTimeout too:

import React from "react";
import { store, view } from "@risingstack/react-easy-state";

const counterStore = store({
  counters: []
});

export default view(() => {
  const addCounter = () => {
    setTimeout(() => {
      counterStore.counters.push({ value: 0 });
      const counter = counterStore.counters[counterStore.counters.length - 1];
      setTimeout(() => {
        counter.value++;
        console.log("No re-render here!");
      }, 1000);
    }, 1000);
  };
  const incCounter = () => {
    counterStore.counters[counterStore.counters.length - 1].value++;
  };
  return (
    <>
      <button onClick={() => addCounter()}>Add counter</button>
      <button onClick={() => incCounter()}>Increment counter</button>
      <ul>
        {counterStore.counters.map(counter => (
          <li>{counter.value}</li>
        ))}
      </ul>
    </>
  );
});

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions