Skip to content

[Feature] useAutoSave - A useHook to automatically save drafts (posts, documents, any JSON) to local storage/cache/db #352

@HarjjotSinghh

Description

@HarjjotSinghh

You are welcome to customize this to your liking and project needs.

'use client';

import { createPost, updatePost } from '@/lib/api';
import { invalidateCache } from '@/lib/cache';
import { useAuth } from '@repo/auth/client';
import { useCallback, useEffect, useRef, useState } from 'react';

interface AutoSaveOptions {
  debounceMs?: number;
  onSave?: (draftId: string) => void;
  onError?: (error: Error) => void;
}

interface DraftData {
  content: string;
  hashtags?: string[];
}

export function useAutoSave(options: AutoSaveOptions = {}) {
  const { debounceMs = 2000, onSave, onError } = options;
  const { getToken } = useAuth();
  const [draftId, setDraftId] = useState<string | null>(null);
  const [isSaving, setIsSaving] = useState(false);
  const [lastSaved, setLastSaved] = useState<Date | null>(null);
  const timeoutRef = useRef<NodeJS.Timeout>(null);
  const dataRef = useRef<DraftData>({ content: '' });

  const saveDraft = useCallback(
    async (data: DraftData) => {
      if (!data.content.trim()) return;

      try {
        setIsSaving(true);
        const token = await getToken();

        if (draftId) {
          // Update existing draft
          await updatePost(
            draftId,
            {
              content: data.content,
              hashtags: data.hashtags || [],
            },
            token || undefined
          );
        } else {
          // Create new draft
          const response = await createPost(
            {
              content: data.content,
              status: 'draft',
              hashtags: data.hashtags || [],
            },
            token || undefined
          );
          setDraftId(response.id);
        }

        setLastSaved(new Date());
        
        // Invalidate drafts cache to ensure fresh data
        invalidateCache.drafts();
        
        onSave?.(draftId || 'new');
      } catch (error) {
        const err = error as Error;
        onError?.(err);
        console.error('Auto-save error:', err);
      } finally {
        setIsSaving(false);
      }
    },
    [draftId, getToken, onSave, onError]
  );

  const debouncedSave = useCallback(
    (data: DraftData) => {
      // Clear existing timeout
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }

      // Update the ref with latest data
      dataRef.current = data;

      // Set new timeout
      timeoutRef.current = setTimeout(() => {
        saveDraft(data);
      }, debounceMs);
    },
    [saveDraft, debounceMs]
  );

  const saveNow = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    saveDraft(dataRef.current);
  }, [saveDraft]);

  const clearDraft = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    setDraftId(null);
    setLastSaved(null);
    dataRef.current = { content: '' };
  }, []);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
      }
    };
  }, []);

  // Auto-save on page unload
  useEffect(() => {
    const handleBeforeUnload = async () => {
      if (dataRef.current.content.trim() && !isSaving) {
        // Use navigator.sendBeacon for reliable saving on page unload
        const token = await getToken();
        if (token) {
          const data = JSON.stringify({
            content: dataRef.current.content,
            status: 'draft',
            hashtags: dataRef.current.hashtags || [],
          });

          navigator.sendBeacon('/api/posts', data);
        }
      }
    };

    window.addEventListener('beforeunload', handleBeforeUnload);
    return () => window.removeEventListener('beforeunload', handleBeforeUnload);
  }, [getToken, isSaving]);

  return {
    draftId,
    isSaving,
    lastSaved,
    debouncedSave,
    saveNow,
    clearDraft,
  };
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions