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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions