Consuming Web Streams with useState, SWR and React Query

fibonacid

Lorenzo Rivosecchi

Posted on July 10, 2023

Consuming Web Streams with useState, SWR and React Query

In this blog post I present you three different ways you can consume text streams with React.

Let's imagine we are building a chatbot interface that will send user messages to an API that will stream down the response one token at the time.

If you are interested in a full guide to build a chatbot refer to this older post or look at the GitHub repository for this article.


Streaming

To build the interface we need a function that calls an API and allows the application to read incoming tokens.
We can use a Javascript Generator to iterate through an async stream of tokens using a simple for loop.

const wait = (ms: number) =>
  new Promise((resolve) => setTimeout(resolve, ms));

// This is how the tokens are made available 
async function* getCompletion(prompt: string) {
  await wait(300);
  yield "Ciao, ";   // token: Ciao, 
  await wait(300);  
  yield "Come ";    // token: Come 
  await wait(300);
  yield "Stai?";    // token: Stai?
}

// This is how the consumer can read the tokens
for await (const token of getCompletion("Ciao")) {
  console.log("token:", token);
}
Enter fullscreen mode Exit fullscreen mode

Here is the actual implementation:

// src/getCompletion.ts
async function* getCompletion(
  prompt: string,
  signal?: AbortSignal,
) {
  const url = new URL(
    "/completion",
    "http://localhost:4000",
  );
  url.searchParams.append("prompt", prompt);

  const res = await fetch(url, {
    method: "GET",
    signal,
  });

  const reader = res.body?.getReader();
  if (!reader) throw new Error("No reader");
  const decoder = new TextDecoder();

  let i = 0;
  while (i < 1000) {
    i++;
    const { done, value } = await reader.read();
    if (done) return;
    const token = decoder.decode(value);
    yield token;

    if (signal?.aborted) {
      await reader.cancel();
      return;
    }
  }
}

export default getCompletion;
Enter fullscreen mode Exit fullscreen mode

https://github.com/fibonacid/streaming-use-effect-react-query-swr/blob/main/src/lib/shared.ts


useEffect + useState

To render the tokens in a React application we need to wrap the getCompletion function in a hook.
Let's create a custom hook that uses only react primitives (useState, useCallback);

// src/useCompletion.ts
import { useCallback, useState } from "react";
import getCompletion from "getCompletion";

export default function useCompletion() {
  const [tokens, setTokens] = useState<string[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<unknown>();
  const [abortController, setAbortController] =
    useState<AbortController | null>(null);

  const mutate = useCallback(
    async (prompt: string) => {
      setIsLoading(true);
      setTokens([]);

      if (abortController) {
        abortController.abort();
      }
      const controller = new AbortController();
      const signal = controller.signal;
      setAbortController(controller);

      try {
        for await (const token of getCompletion(
          prompt,
          signal,
        )) {
          console.log("token", token);
          setTokens((prev) => [...prev, token]);
        }
      } catch (err) {
        if (
          err instanceof Error &&
          err.name === "AbortError"
        ) {
          return; // abort errors are expected
        }
        setError(err);
      }
      setIsLoading(false);
      setAbortController(null);
    },
    [abortController],
  );

  const data = tokens.join("");
  return [mutate, { data, isLoading, error }] as const;
}
Enter fullscreen mode Exit fullscreen mode

https://github.com/fibonacid/streaming-use-effect-react-query-swr/blob/main/src/lib/custom.tsx

Now, let's create a simple component that allow users to send a prompt and read the incoming response.

// src/App.tsx
import useCompletion from "./useCompletion"; 

export default function App() {
  const [prompt, setPrompt] = useState("");
  const [mutate, { data }] = useCompletion();
  return (
    <main>
      <h1>Chat</h1>
      <form
        onClick={() => {
          setPrompt("");
          void mutate(prompt);
        }}
      >
        <label htmlFor="prompt">Prompt</label>
        <input
          id="prompt"
          onChange={(e) => setPrompt(e.currentTarget.value)}
          value={prompt}
        />
        <button>Send</button>
      </form>
      {data && <p>{data}</p>}
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

SWR

This next implementation uses the swr package to store the streaming data. This implementation has been copied and simplified from the ai package from Vercel.

// src/useCompletionSWR.ts

import { useId } from "react";
import useSWR from "swr";
import useSWRMutation from "swr/mutation";
import getCompletion from "./getCompletion";

export default function useCompletionSWR() {
  const id = useId();
  const { data, mutate } = useSWR<string>(
    ["completion", id],
    null,
  );
  const [abortController, setAbortController] =
    useState<AbortController | null>();

  const { trigger, isMutating, error } = useSWRMutation<
    void,
    unknown,
    [string, string],
    string
  >(["completion", id], async (_, { arg: prompt }) => {
    void mutate("", false);
    if (abortController) {
      abortController.abort();
    }
    const controller = new AbortController();
    const signal = controller.signal;
    setAbortController(controller);

    for await (const token of getCompletion(
      prompt,
      signal,
    )) {
      void mutate(
        (prev) => (prev ? prev + token : token),
        false,
      );
    }
    setAbortController(null);
  });

  return [
    trigger,
    { data, error, isLoading: isMutating },
  ] as const;
}
Enter fullscreen mode Exit fullscreen mode

https://github.com/fibonacid/streaming-use-effect-react-query-swr/blob/main/src/lib/custom.tsx


React Query

This last example is very similar to the swr one, but uses React Query.

// src/useCompletionRQ
import { useId } from "react";
import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query";

export default function useCompletionRQ() {
  const id = useId();

  const queryClient = useQueryClient();
  const { data } = useQuery<string>({
    queryKey: ["completion", id],
  });
  const [abortController, setAbortController] =
    useState<AbortController | null>();

  const { mutate, error, isLoading } = useMutation({
    mutationKey: ["completion", id],
    mutationFn: async (prompt: string) => {
      if (abortController) {
        abortController.abort();
      }
      const controller = new AbortController();
      const signal = controller.signal;
      setAbortController(controller);

      for await (const token of getCompletion(
        prompt,
        signal,
      )) {
        queryClient.setQueryData<string>(
          ["completion", id],
          (prev) => (prev ? prev + token : token),
        );
      }
      setAbortController(null);
    },
  });

  return [mutate, { data, error, isLoading }] as const;
}
Enter fullscreen mode Exit fullscreen mode

https://github.com/fibonacid/streaming-use-effect-react-query-swr/blob/main/src/lib/react-query.tsx

We can render the same <App /> element but React Query requires a global store:

// src/App.tsx
import {
  QueryClient,
  QueryClientProvider,
} from "@tanstack/react-query";
import useCompletion from "./useCompletionRQ"; 

const queryClient = new QueryClient();

function Chat() {
  const [mutate, { data }] = useCompletionRQ();
  return <main>{/* ... */}</main>
}

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Chat />
    </QueryClientProvider />
  )
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Conclusion

The option that uses useState is very simple and provides the same functionality as the other ones.
The React Query and SWR versions might be useful if your application heavily relies on one of those libraries and you would like to keep things consistent.

💖 💪 🙅 🚩
fibonacid
Lorenzo Rivosecchi

Posted on July 10, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related