TryAITryAITry TryAI

Shared chat

Convert Image Upload Hook

I have a hook called "useImageUploadManager" that I had been using in my React Native project to upload images. However, I want to reformat it so that it works in my NextJS project. Can you update the hook so that it works seamlessly in my NextJS project. Feel free to make any code improvements as you see fit. ` import { useState, useEffect, useCallback, useRef } from 'react'; import * as ImagePicker from "expo-image-picker"; import { callUploadAsset } from '@/api/api'; import { useAuthStore } from '@/store/AuthStore'; export enum UploadStatus { PENDING='pending', UPLOADING='uploading', COMPLETED='completed', FAILED='failed', }; export type ImageUpload = { status: UploadStatus; base64: string; uploadedUri?: string; }; const useImageUploadManager = (concurrentUploads = 3) => { // Main state object to track all image information const session = useAuthStore((state) => state.session); const [imageMap, setImageMap] = useState<{ [key: string]: ImageUpload}>({}); // Ref to always access latest imageMap value const imageMapRef = useRef<{ [key: string]: ImageUpload}>({}); // Format: { // [localUri]: { // status: UploadStatus, // uploadedUri: string | null, // error: Error | null, // timestamp: number // } // } const uploadQueue = useRef<string[]>([]); const activeUploads = useRef(new Set()); const uploading = useRef(false); useEffect(() => { imageMapRef.current = imageMap; }, [imageMap]); // Simulated upload API call const uploadImage = async ( base64: string, fileName: string, accessToken?: string ) => { try { const response = await callUploadAsset(accessToken, base64, fileName, "s3"); if (response.url) { return { success: true, uploadedUri: response.url }; } else { return { success: false, error: "There was an error parsing the result" }; } } catch (error) { return { success: false, error }; } }; const removeImage = (uri: string) => { if (imageMap[uri].status !== UploadStatus.FAILED && imageMap[uri].status !== UploadStatus.COMPLETED) { return; } setImageMap((previousState) => { const newState = { ...previousState }; delete newState[uri]; return newState; }); } // Process the upload queue const processQueue = useCallback(async () => { if (uploadQueue.current.length === 0 || uploading.current) return; uploading.current = true; let count = 0; while (activeUploads.current.size < concurrentUploads && uploadQueue.current.length > 0 && count < 10) { count += 1 const localUri = uploadQueue.current[0]; // Skip if already uploading or completed if (activeUploads.current.has(localUri) || imageMapRef.current[localUri]?.status === UploadStatus.COMPLETED || imageMapRef.current[localUri]?.status === UploadStatus.FAILED) { uploadQueue.current = uploadQueue.current.slice(1); continue; } // Start upload activeUploads.current.add(localUri); setImageMap(prev => ({ ...prev, [localUri]: { ...prev[localUri], status: UploadStatus.UPLOADING, timestamp: Date.now() } })); // Remove from queue // setUploadQueue(prev => prev.slice(1)); uploadQueue.current.slice(1); const fileName = localUri.split("/").at(-1) as string; // Process upload uploadImage(imageMapRef.current[localUri].base64, fileName, session?.access_token).then(result => { activeUploads.current.delete(localUri); setImageMap(prev => ({ ...prev, [localUri]: { ...prev[localUri], status: result.success ? UploadStatus.COMPLETED : UploadStatus.FAILED, uploadedUri: result.success ? result.uploadedUri : undefined, error: result.success ? null : result.error } })); if (!result.success) { // Handle failed upload - optionally retry // TODO: should retries happen? // setUploadQueue(prev => [...prev, localUri]); } // Continue processing queue after items are removed from the active uploads processQueue(); }); } // if (uploadQueue.current.length > 0) { // processQueue(); // } uploading.current = false; }, [concurrentUploads]); // Add new images to be uploaded const addImages = useCallback((newLocalAssets: ImagePicker.ImagePickerAsset[]) => { // Filter out any URIs that are already being tracked const uniqueNewAssets = newLocalAssets.filter(asset => !imageMap[asset.uri] && !!asset.base64); const uniqueNewUris = uniqueNewAssets.map((asset) => asset.uri); if (uniqueNewUris.length === 0) return; // Add new images to the imageMap setImageMap(prev => { const updates: { [key: string]: ImageUpload } = {}; uniqueNewAssets.forEach(asset => { updates[asset.uri] = { status: UploadStatus.PENDING, base64: asset.base64 as string }; }); return { ...prev, ...updates }; }); // Add to upload queue // setUploadQueue(prev => [...prev, ...uniqueNewUris]); uploadQueue.current = [...uploadQueue.current, ...uniqueNewUris]; processQueue(); }, [imageMap]); // Helper method to get all images with a specific status const getImagesByStatus = useCallback((status: UploadStatus) => { return Object.entries(imageMap) .filter(([_, data]) => data.status === status) .map(([localUri, data]) => ({ localUri, ...data })); }, [imageMap]); return { removeImage, addImages, imageMap, setImageMap, pendingUploads: uploadQueue.current.length + activeUploads.current.size, getImagesByStatus, getPendingImages: () => getImagesByStatus(UploadStatus.PENDING), getUploadingImages: () => getImagesByStatus(UploadStatus.UPLOADING), getCompletedImages: () => getImagesByStatus(UploadStatus.COMPLETED), getFailedImages: () => getImagesByStatus(UploadStatus.FAILED), }; }; export default useImageUploadManager; `

Below is a completely browser-friendly version of useImageUploadManager that you can drop straight into a Next JS (React-DOM) project.

Key differences from the original React-Native / Expo implementation

• No expo-image-picker – the hook now works with ordinary File / FileList objects coming from an or any file picker library.
• Local “uri” is now a generated id + a blob‐URL preview so we can still display the image before it is uploaded.
• File → base-64 conversion is handled with FileReader.
• The subtle bug where the queue item was never actually removed (uploadQueue.current.slice(1)) has been fixed – we now use shift().
• Some general clean-ups (stronger typing, safer refs, SSR friendly, smaller helper functions, URL.revokeObjectURL on remove, etc.).

You can of course tailor the hook even further (progress callbacks, retry strategies, etc.) but this is a solid, drop-in, Next JS-ready replacement.

ts
/* -------------- hooks/useImageUploadManager.ts ----------------- *//* eslint-disable consistent-return */'use client';
import { useCallback, useEffect, useRef, useState } from 'react';import { callUploadAsset } from '@/api/api';import { useAuthStore } from '@/store/AuthStore';
/* ---------------------------------------------------------------- *  Types / helpers * ---------------------------------------------------------------- */export enum UploadStatus {  PENDING    = 'pending',  UPLOADING  = 'uploading',  COMPLETED  = 'completed',  FAILED     = 'failed',}
export interface ImageUpload {  status      : UploadStatus;  base64      : string;  fileName    : string;  previewUrl  : string;        // local blob url – handy for <img src='...' />  uploadedUri?: string;        // final CDN / S3 url after success  error?      : unknown;}
/** Simple unique id generator – fine for local purposes */const genId = () => `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
/** Convert a File to base64 */const fileToBase64 = (file: File) =>  new Promise<string>((resolve, reject) => {    const reader = new FileReader();    reader.onerror = () => reject(new Error('Could not read file'));    reader.onload  = () => resolve(reader.result as string);    reader.readAsDataURL(file);  });
/* ---------------------------------------------------------------- *  Hook * ---------------------------------------------------------------- */const useImageUploadManager = (concurrentUploads = 3) => {  /* Global auth data (only needed if your API needs an access token) */  const accessToken = useAuthStore((s) => s.session?.access_token);
  /* Main image map –  id  -> ImageUpload */  const [imageMap, setImageMap] = useState<Record<string, ImageUpload>>({});  const imageMapRef             = useRef(imageMap);  useEffect(() => { imageMapRef.current = imageMap; }, [imageMap]);
  /* Queue / concurrency bookkeeping */  const uploadQueue  = useRef<string[]>([]);  const activeUploads = useRef<Set<string>>(new Set());  const processing    = useRef(false);
  /* ------------------------------------------------------------   *  Internal – actually upload a single image   * ------------------------------------------------------------ */  const uploadImage = async (base64: string, fileName: string) => {    try {      const res = await callUploadAsset(accessToken, base64, fileName, 's3');      if (res?.url) {        return { success: true as const, uploadedUri: res.url };      }      return { success: false as const, error: 'Bad response format' };    } catch (err) {      return { success: false as const, error: err };    }  };
  /* ------------------------------------------------------------   *  Queue processor – keeps starting new uploads   * ------------------------------------------------------------ */  const processQueue = useCallback(async () => {    if (processing.current) return;        // already spinning    processing.current = true;
    while (      activeUploads.current.size < concurrentUploads &&      uploadQueue.current.length    ) {      const id = uploadQueue.current.shift()!;         // dequeue
      // skip if already done/ongoing      const img = imageMapRef.current[id];      if (!img || img.status === UploadStatus.COMPLETED) continue;      if (activeUploads.current.has(id)) continue;
      /* Mark as uploading */      activeUploads.current.add(id);      setImageMap((prev) => ({        ...prev,        [id]: { ...prev[id], status: UploadStatus.UPLOADING },      }));
      /* Perform the upload */      uploadImage(img.base64, img.fileName).then((result) => {        activeUploads.current.delete(id);
        setImageMap((prev) => ({          ...prev,          [id]: {            ...prev[id],            status      : result.success ? UploadStatus.COMPLETED : UploadStatus.FAILED,            uploadedUri : result.success ? result.uploadedUri : undefined,            error       : result.success ? undefined : result.error,          },        }));
        /* kick the loop again (in case new items got queued) */        processQueue();      });    }
    processing.current = false;  }, [concurrentUploads, accessToken]);
  /* ------------------------------------------------------------   *  Public API   * ------------------------------------------------------------ */
  /* 1) Add files coming from <input type="file" multiple /> etc. */  const addFiles = useCallback(    async (files: FileList | File[]) => {      const fileArr  = Array.from(files);      if (!fileArr.length) return;
      const newEntries: Record<string, ImageUpload> = {};      const newIds: string[] = [];
      /* Convert to base64 in parallel */      await Promise.all(        fileArr.map(async (file) => {          const id        = genId();          const base64Str = await fileToBase64(file);          const blobUrl   = URL.createObjectURL(file);
          newIds.push(id);          newEntries[id] = {            status     : UploadStatus.PENDING,            base64     : base64Str,            fileName   : file.name,            previewUrl : blobUrl,          };        }),      );
      if (!newIds.length) return;
      /* Commit to state + queue */      setImageMap((prev) => ({ ...prev, ...newEntries }));      uploadQueue.current.push(...newIds);      processQueue();    },    [processQueue],  );
  /* 2) Remove an image (allowed only when finished / failed) */  const removeImage = useCallback((id: string) => {    const target = imageMapRef.current[id];    if (!target) return;    if (target.status === UploadStatus.PENDING || target.status === UploadStatus.UPLOADING) return;
    URL.revokeObjectURL(target.previewUrl);   // cleanup memory    setImageMap((prev) => {      const next = { ...prev };      delete next[id];      return next;    });  }, []);
  /* 3) Convenience getters -------------------------------------------------- */  const getImagesByStatus = useCallback(    (status: UploadStatus) =>      Object.entries(imageMap)        .filter(([, v]) => v.status === status)        .map(([id, v]) => ({ id, ...v })),    [imageMap],  );
  return {    /* state */    imageMap,
    /* actions */    addFiles,    removeImage,
    /* helpers */    pendingUploads   : uploadQueue.current.length + activeUploads.current.size,    getPendingImages : () => getImagesByStatus(UploadStatus.PENDING),    getUploadingImages : () => getImagesByStatus(UploadStatus.UPLOADING),    getCompletedImages : () => getImagesByStatus(UploadStatus.COMPLETED),    getFailedImages    : () => getImagesByStatus(UploadStatus.FAILED),  };};
export default useImageUploadManager;

How to use inside a component (simplified example)

tsx
'use client';import useImageUploadManager from '@/hooks/useImageUploadManager';
export default function Uploader() {  const {    addFiles,    getUploadingImages,    getCompletedImages,    removeImage,  } = useImageUploadManager(2);   // two concurrent uploads
  return (    <div>      <input        type="file"        accept="image/*"        multiple        onChange={(e) => e.target.files && addFiles(e.target.files)}      />
      <h3>Uploading…</h3>      {getUploadingImages().map((img) => (        <img key={img.id} src={img.previewUrl} width={100} />      ))}
      <h3>Finished</h3>      {getCompletedImages().map((img) => (        <div key={img.id}>          <img src={img.previewUrl} width={100} />          <button onClick={() => removeImage(img.id)}>remove</button>        </div>      ))}    </div>  );}

Enjoy – your image uploading logic is now fully adapted for the browser / Next JS environment!

openai/o3
OK, I have an ImageIconButton component that I'm using to render an image icon. Once the icon is pressed a user can add local image files to upload into my service. Can you update my ImageIconButton component to use this hook that allows users to upload files: ` import { ImageIcon } from "./icons"; import { Button } from "./ui/button"; interface ImageIconButtonProps {} const ImageIconButton = (props: ImageIconButtonProps) => { return ( <button className="rounded-sm p-1.5 h-fit hover:bg-zinc-200 dark:hover:bg-zinc-700" onClick={(e) => { e.preventDefault(); stop(); }} > <ImageIcon /> </button> ); }; export default ImageIconButton; `

Here’s an updated, browser-friendly ImageIconButton that wires itself to the new useImageUploadManager hook.
When the user presses the icon we programmatically click a hidden ; once (multiple) images are chosen they’re passed straight to addFiles coming from the hook.

If you already call useImageUploadManager once higher in the tree, simply pass the addFiles function to this component instead of letting the button create its own manager instance (see comment in code).

ImageIconButton.tsx

tsx
'use client';
import { useRef, ChangeEvent, MouseEvent } from 'react';import { ImageIcon } from './icons';import { Button } from './ui/button';import useImageUploadManager from '@/hooks/useImageUploadManager';
/* ------------------------------------------------------------------ *//* If you keep a single upload manager higher in the tree you can do  * *   const { addFiles } = useImageUploadManager();                    * * there and then pass `addFiles` down through props.                  * * ------------------------------------------------------------------ */
interface ImageIconButtonProps {  /** Optional: reuse an existing addFiles from a parent manager */  addFilesFromParent?: (files: FileList) => void;  /** Allow caller to customise accepted mime-types (default images) */  accept?: string;  /** Allow caller to specify whether multiple selection is allowed */  multiple?: boolean;}
const ImageIconButton = ({  addFilesFromParent,  accept = 'image/*',  multiple = true,}: ImageIconButtonProps) => {  /* Either use the parent’s addFiles or create our own manager here */  const { addFiles: addFilesLocal } = useImageUploadManager();  const addFiles = addFilesFromParent ?? addFilesLocal;
  const fileInputRef = useRef<HTMLInputElement>(null);
  /* Trigger the hidden file input */  const handlePress = (e: MouseEvent<HTMLButtonElement>) => {    e.preventDefault();    fileInputRef.current?.click();  };
  /* When the user picks files => forward them to the hook */  const handleFilesSelected = (e: ChangeEvent<HTMLInputElement>) => {    if (e.target.files && e.target.files.length) {      addFiles(e.target.files);      /* Reset the value so picking the same file again fires onChange */      e.target.value = '';    }  };
  return (    <>      {/* You can keep using your own <Button> component instead */}      <button        type="button"        onClick={handlePress}        className="rounded-sm p-1.5 h-fit hover:bg-zinc-200 dark:hover:bg-zinc-700"        aria-label="Add images"      >        <ImageIcon />      </button>
      <input        ref={fileInputRef}        type="file"        accept={accept}        multiple={multiple}        onChange={handleFilesSelected}        className="hidden"      />    </>  );};
export default ImageIconButton;

What changed / why it works:

  1. Hidden file input – lets us stay in the browser world (no Expo image-picker).
  2. addFiles(files) – we forward the chosen FileList to the new hook, which converts them to base-64, queues them and starts uploading.
  3. e.target.value = '' resets the input so the user can re-select the same file.
  4. “use client” directive is added because Next 13 server components must explicitly opt into client-side behaviour.

That’s it; the icon button is now fully integrated with the new upload manager!

openai/o3

This is a shared TryAI chat. Sign in to start your own conversation.

Sign in to TryAI