← All articles

A React upload hook with explicit async state

Selecting a second upload creates a race: a late response from the first request must not replace the newer choice. A sequence number guards the visible state.

t.Turgay
A React upload hook with explicit async state

File A is uploading when the user selects B. B finishes first, then A's late response overwrites the newer preview URL. The storage service can succeed for both requests while the UI still behaves incorrectly. The storage-hook approach in my earlier portfolio experiment is useful for explaining that asynchronous ordering problem.

Inject the provider operation

useUpload accepts a send function that uploads one file and resolves to a URL. The hook owns screen state rather than every detail of a storage SDK. This independent .tsx example belongs in a React client project; the actual endpoint, authentication and file validation are separate requirements.

import {useRef,useState,useEffect} from 'react';
export function useUpload(send:(file:File)=>Promise<string>){
  const [state,setState]=useState<{status:string;url?:string}>({status:'idle'});
  const sequence=useRef(0);
  useEffect(()=>()=>{sequence.current++;},[]);
  async function upload(file:File){
    const current=++sequence.current;
    setState({status:'uploading'});
    try{
      const url=await send(file);
      if(current===sequence.current)setState({status:'ready',url});
    }catch{
      if(current===sequence.current)setState({status:'error'});
    }
  }
  return {state,upload};
}

Each call advances sequence and captures its current value. A result changes state only if its request is still current. Once B starts, A cannot pass that comparison. The catch branch uses the same check, so an older failure cannot turn B's successful preview into an error.

Make the state machine visible to TypeScript

The compact original uses a string status. A discriminated union can restrict that value to the known states and require the URL only for ready. This replaces the state declaration inside the hook:

type UploadState =
  | {status: 'idle'}
  | {status: 'uploading'}
  | {status: 'ready'; url: string}
  | {status: 'error'};

const [state, setState] = useState<UploadState>({status: 'idle'});

Consumers then branch before reading the URL rather than treating an old optional URL as valid during uploading. A practical workflow may add safe error information or progress, but distinct states explain permitted combinations more clearly than making every field optional everywhere.

A sequence guard is not cancellation

The counter prevents a stale UI update; A may still finish and store an object on the server. Cancellation needs explicit support in the send adapter, for example an agreed AbortSignal contract. Cancelling a network request also does not automatically remove an object already accepted by the server.

A multi-file screen may need independent state per file instead of this latest-request-wins policy. A correct solution for one preview should not be generalized into a queue that silently disregards earlier intended uploads.

Cleanup invalidates results after unmount

The effect cleanup advances the sequence when the component disappears, invalidating pending results. Its behavior should remain safe under development Strict Mode lifecycle checks. Ignoring a completed request after unmount still does not mean the network work was stopped.

Uploaded and attached to content can be separate states

Receiving a file URL is not the same as saving it as an article cover. If the later article save fails, an uploaded object can be left without an owner reference. Temporary upload identities, explicit attachment or controlled cleanup can address that lifecycle. The meaning of ready should match the actual step completed.

The server remains responsible for file types, size and access control. Browser checks improve experience but do not replace those rules, and storage secrets must not be embedded in the hook.

Test the race with controlled promises

Start A and B with promises whose completion can be controlled. Resolve B, then A; the result should remain B's ready state. Rejecting A afterwards must not replace it with error. Also test a failure of the latest request and cleanup during a pending upload. These checks exercise the state contract without uploading a large real file.

React effect cleanup; TypeScript discriminated unions.

✳ You have reached the end of this orbit.
Explore more articles →
turgay.A little code, a few pixels, a lot of curiosity.© 2026 · Made on Earth ✳