Skip to content

Instantly share code, notes, and snippets.

@jeremy-code
Last active April 14, 2026 06:09
Show Gist options
  • Select an option

  • Save jeremy-code/8ca2001db0b30c5935fa303727c06fe5 to your computer and use it in GitHub Desktop.

Select an option

Save jeremy-code/8ca2001db0b30c5935fa303727c06fe5 to your computer and use it in GitHub Desktop.
Using Files or Blobs in @tanstack/react-query

If you're working with Files or Blob objects, you can't really do anything with them besides read their size and type unless you use one of its methods (e.g. bytes, arrayBuffer, slice, stream, text), all of which (besides stream) return a Promise.

For me, in React, if I'm expected to handle a Promise, my mind gravitates to either React Query or SWR. Something like this:

import { useQuery } from "@tanstack/react-query";

const Component = ({ file }: { file: File }) => {
  const { data: arrayBuffer } = useQuery({
    queryKey: ["Component", file],
    queryFn: () => file.arrayBuffer(),
  });
  // ...whatever
};

But since query keys are serialized into strings by default using JSON.stringify, your queryKey would end up looking like this: ["Component",{}]. The easiest "solution", and one that will temporarily fix your errors, is to just use the properties avaliable as a queryKey.

const serializeFile = (file: File) => {
  return {
    lastModified: file.lastModified,
    name: file.name,
    webkitRelativePath: file.webkitRelativePath,
    size: file.size,
    type: file.type,
  };
};
// ...
  const { data: arrayBuffer } = useQuery({
    queryKey: ["Component", file, serializeFile(file)],
    queryFn: () => file.arrayBuffer(),
  });

However, this really isn't ideal for a couple reasons. For one, in Blobs, only size and type are avaliable, which really aren't that unique of an identifier. lastModified in Files also just returns the current time by default, so that may be too unique of an identifier.

The solution it seems would be to hash the File somehow, and use that as the queryKey. The thing is, while queryKeyHashFn does exist as a property, it seems to only accept synchronous functions. I really can't find any hashing function library that is synchronous, and it would be difficult to roll your own I imagine (SubtleCrypto.digest returns a promise).

I'm using sha256 from hash-wasm for reference.

My first thought was to return to old-fashioned useEffect, which compares directly by reference. Below is a more simplified version of this file.

import { useEffect, useState } from "react";

import { sha256 } from "hash-wasm";

type UseFileHashResult = {
  fileHash: string | null;
  isPending: boolean;
  error: Error | null;
};

const useFileHash = (file: File | undefined | null): UseFileHashResult => {
  const [fileHashState, setFileHashState] = useState<UseFileHashResult>(() =>
    !file ?
      { fileHash: null, isPending: false, error: null }
    : { fileHash: null, isPending: true, error: null },
  );

  useEffect(() => {
    if (!file) {
      return;
    }

    const abortController = new AbortController();

    const computeHash = async () => {
      setFileHashState({ fileHash: null, isPending: true, error: null });
      try {
        const fileInBytes = await file.bytes();
        const fileHash = await sha256(fileInBytes);
        if (abortController.signal.aborted) {
          return;
        }
        setFileHashState({ fileHash, isPending: false, error: null });
      } catch (error) {
        if (abortController.signal.aborted) {
          return;
        }
        setFileHashState({
          fileHash: null,
          isPending: false,
          error: error instanceof Error ? error : new Error(String(error)),
        });
      }
    };

    void computeHash();
    return () => abortController.abort();
  }, [file]);

  return fileHashState;
};

export { useFileHash };

In which case, you would use it like this:

const Component = ({ file }: { file: File }) => {
  const { fileHash } = useFileHash(file);
  const { data: arrayBuffer } = useQuery({
    queryKey: ["Component", file, fileHash],
    queryFn: () => file.arrayBuffer(),
    enabled: !!fileHash,
  });
  // ...whatever
  return <div>{arrayBuffer ? "Loaded" : "Loading..."}</div>;
};

It works... but I'm not really a fan. I have been mostly using useSuspsenseQuery and setting enabled is not possible in that case. Furthermore, the code is a bit too verbose and overengineered for something that should be fairly simple.

Alternatively, I tried using React 19's use hook.

const Parent = ({ file }: { file: File }) => {
  const fileHashPromise = file
    .bytes()
    .then((fileInBytes) => sha256(fileInBytes));

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Child file={file} fileHashPromise={fileHashPromise} />
    </Suspense>
  );
};
const Child = ({ file, fileHashPromise }: { file: File, fileHashPromise: Promise<string> }) => {
  const fileHash = use(fileHashPromise);
  const { data: arrayBuffer } = useSuspenseQuery({
    queryKey: ["Component", file, fileHash],
    queryFn: () => file.arrayBuffer(),
  });
  // ...whatever
}

Since I already have Suspense around my components due to using useSuspenseQuery, I thought this approach was better and less intrusive.

Still, it is a bit frustrating have the strange nested structure, especially since it's only dependent on file.

As a quick aside, it does raise the question: why doesn't this work:

const Child = ({ file }: { file: File }) => {
  const fileHash = use(file.bytes().then((fileInBytes) => sha256(fileInBytes)));
  // ...whatever
}

Do something like this, and the component will infinitely re-render. More specifically, if we read the docs, it says: "Promises created in Client Components are recreated on every render." We can see this is true by doing something like this:

  const fileHash = use(
    file
      .bytes()
      .then((fileInBytes) => sha256(fileInBytes))
      .then((hash) => console.log("promise", hash)),
  );
  // repeats promise and the hash repeatedly

So what if we were to make sure the Promise were stable?

const fileHashPromiseCache = new WeakMap<File, Promise<string>>();

const getFileHashPromise = (file: File) => {
  let fileHashPromise = fileHashPromiseCache.get(file);
  if (fileHashPromise === undefined) {
    fileHashPromise = file.bytes().then((fileInBytes) => sha256(fileInBytes));
    fileHashPromiseCache.set(file, fileHashPromise);
  }
  return fileHashPromise;
};

const Child = ({ file }: { file: File }) => {
  const fileHash = use(getFileHashPromise(file));
  const { data: arrayBuffer } = useSuspenseQuery({
    queryKey: ["Component", file, fileHash],
    queryFn: () => file.arrayBuffer(),
  });
  // ...whatever
}

Surprisingly, this DOES work.

Let me know if you have any better ideas. I am not sure whether declaring fileHashPromiseCache in the module top-level has consequences that I should be concerned of.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment