Skip to content

[Feature Request]: Add fetch and Server-Sent Events supports #36145

@kingcean

Description

@kingcean

Purpose

Fetch data from back-end services is the most often used scenarios in an app (especially in a web app).

Add useFetchJSON and useFetchSSE hook APIs to add supports for HTTP fetch and server-sent event (a.k.a SSE). The arguments of these should looks similar fetch API.

export function useFetchJSON<T = any>(input: RequestInfo | URL, init?: RequestInit, dependencies: DependencyList): {
  value: T;
  loading: boolean;
  error: any;
};

export function useFetchSSE(input: RequestInfo | URL, init?: RequestInit, dependencies: DependencyList): {
  list: ServerSentEventItem[];
  loading: boolean;
  error: any;
  length: number;
  last: ServerSentEventItem;
};

The fetch action occurs only once even if the component updates but reserve a dependency list to allow developers have the right of control.

Usages

The way to fetch in React is like call fetch API but it returns an object with following properties:

  • value: The result parsed in JSON; or undefined during sending the request and receiving the response.
  • loading: A value indicating whether it is in progress.
  • error: The error information if fails.
import { useFetchJSON } from 'react';

interface IUserInfo {
  name: string;
  age: number;
}

const fetchOptions = {
  method: "GET",
  mode: "cors",
  credentials: "include",
  headers: {
    "Accept": "application/json"
  }
};

function Something() {
  const { value, loading } = useFetchJSON<IUserInfo>(API_URL, fetchOptions, []);

  return loading
    ? (
      <div>
        <span>Loading…</span>
      </div>
    )
    : (
      <div>
        <span>Name: </span><span>{value.name}</span>
        <br />
        <span>Age: </span><span>{value.age.toString(10)}</span>
      </div>
    );
}

The useFetchSSE hook API is used to expect what the API returns in SSE content type (streaming), e.g. LLM chat API. It returns an object with following properties.

  • list: An array of the result which has alread returned.
  • loading: A value indicating whether the fetch is in progress (including output is still streaming).
  • error: The error information if fails.
  • length: The count of the SSE items already returned. It equals to list.length.
  • last: The last SSE item already returned; or undefined if none during accessing. It equals to list[list.length - 1].
import { useFetchSSE } from 'react';

const fetchOptions: RequestInit = {
  method: "POST",
  mode: "cors",
  credentials: "include",
  headers: {
    "Content-Type": "application/json",
    "Accept": "text/event-stream, application/json"
  }
};

export function StreamingItems() {
  fetchOptions.body = JSON.stringify(REQ_BODY);
  const { list } = useFetchSse(SSE_API_URL, fetchOptions, []);
  return (
    <ul>
      {list.map((e, i) => {
        return e.event === "message" ? <li key={`item-${i}`}>{e.data}</li> : null;
      })}
    </ul>
  )
}

Implementation

import { useEffect, useState } from "react";

export function useFetchJSON<T>(input: RequestInfo | URL, init?: RequestInit, dependencies?: DependencyList) {
  const [value, setValue] = useState<T>();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState();
  useEffect(() => {
    fetch(input, init).then((response) => {
      return response.json().then(r => {
        setLoading(false);
        setValue(r);
        return r as T;
      });
    }).catch((err: any) => {
      setError(err);
      setLoading(false);
    });
  }, dependencies === undefined ? [input, init] : dependencies);
  return {
    value,
    loading,
    error,
  };
}

export function useFetchSSE(input: RequestInfo | URL, init?: RequestInit, dependencies?: DependencyList) {
  const [list, setList] = useState<ServerSentEventItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState();
  useEffect(() => {
    fetch(input, init).then((response) => {
      return handleSse(response, new TextDecoder("utf-8"), item => {
        setList([...list, item]);
      }).then(arr => {
        setLoading(false);
        return arr;
      });
    }).catch((err: any) => {
      setError(err);
      setLoading(false);
    });
  }, dependencies === undefined ? [input, init] : dependencies);
  return {
    list,
    loading,
    error,
    length: list.length,
    last: list.length > 0 ? list[list.length - 1] : undefined,
  };
}

https://kingcean.org/blog/?2025/sse

The function handleSse in useFetchSSE above is the implementation (as following) to convert the HTTP response to SSE items.

async function handleSse(response: Response, decoder: TextDecoder, callback: ((item: ServerSentEventItem) => void)) {
  if (!resp.body) return Promise.reject("no response body");
  const reader = resp.body.getReader();
  let buffer = "";
  const arr: ServerSentEventItem[] = [];

  while (true) {
    const { done, value } = await reader.read();
    if (done) {
      convertSse(buffer, arr, callback);
      return arr;
    }

    buffer += decoder.decode(value, { stream: true });
    const messages = buffer.split("\n\n");
    if (messages.length < 2) continue;
    buffer = messages.pop() || "";
    messages.forEach(msg => {
      convertSse(msg, arr, callback);
    });
  }
}

function convertSse(msg: string, arr: ServerSentEventItem[], callback: ((item: ServerSentEventItem) => void)) {
  if (!msg) return;
  const sse = new ServerSentEventItem(msg);
  arr.push(sse);
  callback(sse);
  return sse;
}

The model ServerSentEventItem mentioned above is following.

export class ServerSentEventItem {
  private source: Record<string, string>;
  private dataParsedInJson: Record<string, unknown> | undefined;

  constructor(source: string) {
    this.source = {};
    (source || "").split("\n").forEach(line => {
      const pos = line.indexOf(":");
      if (pos < 0) return;
      const key = line.substring(0, pos);
      const value = line.substring(pos + 1);
      if (!this.source[key] || (key !== "data" && key !== "")) this.source[key] = value;
      else this.source[key] += value;
    });
  }
  get event() {
    return this.source.event || "message";
  }
  get data() {
    return this.source.data;
  }
  get id() {
    return this.source.id;
  }
  get comment() {
    return this.source[""];
  }
  get retry() {
    return this.source.retry ? parseInt(this.source.retry, 10) : undefined;
  }
  dataJson<T = Record<string, unknown>>() {
    const data = this.source.data;
    if (!data) return undefined;
    if (!this.dataParsedInJson) this.dataParsedInJson = JSON.parse(data);
    return this.dataParsedInJson as T;
  }
  get(key: string) {
    return this.source[key];
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions