Your Client Side State is a Lie

SPAs are really hard to get right, but what exactly is hard about them? As always, its state, but a specific kind of state.

Whenever you fetch data from the server and store that data in any shared state management solution, your application is very often lying to your users.

I would like to first give a nod to a talk that got me thinking about this years ago. It’s Time to Break up with your “Global State”! [Tanner Linsley].

In short tanner argues that server state is different than application state. When you fetch "/books/10" and store it in a global state management solution, you are making things really hard for yourself & opening yourself up to bugs.

To illustrate the pains of using global state management solutions for data fetching, lets take a look at 2 simple components

function ViewBook({ id }) {
  // Plug in your state management library here
  let bookStore = useMyGlobalStore("book");
  useEffect(() => {
    if (!bookStore.has(id)) {
      http.get(`/books/${id}`).then((book) => bookStore.set(id, book));
    }
  }, [id]);
  let book = bookStore.get(id);
  // for simplicity, we are ignoring loading states
  // and handling 404s
  return (
    book && (
      <div>
        <BookDetails data={fetchedBook} />
        <Link to={`/edit_book?id=${fetchedBook.id}`}>Edit</Link>
      </div>
    )
  );
}

function EditBook({ id }) {
  let bookStore = useMyGlobalStore("book");
  useEffect(() => {
    if (!bookStore.has(id)) {
      http.get(`/books/${id}`).then((book) => bookStore.set(id, book));
    }
  }, [id]);

  function save() {
    http.put(`/books/${id}`, bookStore.get(id));
  }

  function updateTitle(title) {
    bookStore.update(id, book => ({...book, title }))
  }

  // for simplicity, we are ignoring loading states
  // and handling 404s
  return (
    book && (
      <form onSubmit={save}>
        <label>
          Title
          <input onChange={e => updateTitle(e.target.value)} type="text"/>
          {/* And so on */}
        </label>
      </form>
    )
  );
}

First lets see what this approach gives us.

We load the book into the store from ViewBook and when we switch to EditBook, the book is already instantly there. No additional data fetching required.

On top of that, we recognize that you may want to go EditBook without first going to ViewBook so we check the store & fetch it when not present.

Now here are some pitfalls of this approach

Like Tanner said, server state is not application state, you should not treat your application store like a cache and you should not update cached data from user input.

Now that we’ve started to see what the issue is, how do we fix it?

Our first attempt

function ViewBook({ id }) {
  let [book, setBook] = useState(null);
  useEffect(() => {
    http.get(`/books/${id}`).then(setBook);
  }, [id]);
  return (
    book && (
      <div>
        <BookDetails data={fetchedBook} />
        <Link to={`/edit_book?id=${fetchedBook.id}`}>Edit</Link>
      </div>
    )
  );
}

function EditBook({ id }) {
  let [book, setBook] = useState(null);
  useEffect(() => {
    http.get(`/books/${id}`).then(setBook);
  }, [id]);

  function save() {
    http.put(`/books/${id}`, book);
  }

  function updateTitle(title) {
    setBook((b) => ({ ...b, title }));
  }

  return (
    book && (
      <form onSubmit={save}>
        <div></div>
        <label>
          Title
          <input onChange={(e) => updateTitle(e.target.value)} type="text" />
          {/* And so on */}
        </label>
      </form>
    )
  );
}

All we did is move the all the state to component level state.

That’s all, and look what that solved.

For many use cases, I think this is actually quite good. On top of that, look how much simpler the code is!

If you are thinking “but fetching every time is slow!”, measure it and then choose sparingly where you want to cache. You would be surprised how few times you actually need to cache.

Is your combined APIs needed for the page already < 500ms to fetch? You should really consider leaving it alone.

Is the page not user-facing? Your budget has increased to at least 2s.

When you need to, use a proper caching solution like react-query.

But wait, there’s more.

We are missing a crucial part to data fetching, letting the user know the status of the data.

function EditBook({ id }) {
  let [book, setBook] = useState(null);
  let [lastFetched, setLastFetched] = useState(null);
  let [IsModified, setIsModified] = useState(false);
  useEffect(() => {
    http.get(`/books/${id}`).then((data) => {
      setBook(data);
      setLastFetched(new Date());
      setIsModified(false);
    });
  }, [id]);

  function save() {
    http.put(`/books/${id}`, book);
  }

  function updateTitle(title) {
    setIsModified(true);
    setBook((b) => ({ ...b, title }));
  }

  return (
    book && (
      <form onSubmit={save}>
        <div>
          Created At: {book.createdAt}
          Modified At: {book.modifiedAt}
          Last Fetched: {lastFetched}
          {IsModified && <div>You have some unsaved changes</div>}
        </div>
        <label>
          Title
          <input onChange={(e) => updateTitle(e.target.value)} type="text" />
          {/* And so on */}
        </label>
      </form>
    )
  );
}

But this is a lot of boilerplate, here’s a simple hook that addresses the boilerplate.

function useManagedResource({ url }) {
  let [data, setData] = useState(null);
  let [lastFetched, setLastFetched] = useState(null);
  let [isModified, setIsModified] = useState(false);
  useEffect(() => {
    http.get(url).then((data) => {
      setData(data);
      setLastFetched();
      setIsModified(false);
    });
  }, [url]);

  function updateData(updateFn) {
    setData(updateFn);
    setIsModified(true);
  }

  return [data, { lastFetched, isModified }, updateData];
}

function EditBook({ id }) {
  let [book, { lastFetched, isModified }, setBook] = useManagedResource(
    `/books/${id}`
  );

  function save() {
    http.put(`/books/${id}`, book);
  }

  function updateTitle(title) {
    setBook((b) => ({ ...b, title }));
  }

  return (
    book && (
      <form onSubmit={save}>
        <div>
          Created At: {book.createdAt}
          Modified At: {book.modifiedAt}
          Last Fetched: {lastFetched}
          {isModified && <div>You have some unsaved changes</div>}
        </div>
        <label>
          Title
          <input onChange={(e) => updateTitle(e.target.value)} type="text" />
          {/* And so on */}
        </label>
      </form>
    )
  );
}

Now we have a pretty good data fetching strategy, and again, the component code got simpler!

Data is always fresh between components, and when a component is being modified, you have access to all that metadata to display as you please.

It becomes clear to see how you could extend this to include caching, loading states, refresh, generic metadata components etc.

If there's one thing you take away from this, don't cache by default. Treat it like an optimization problem. Measure it to verify it is an issue, then see if there's any low hanging fruit on the backend, and only after all of that has been assessed, consider adding caching to your data fetching solution.