Performance Best Practices

Optimize rendering, reduce bundle size, and deliver a smooth document viewing experience.

Zustand Selector Pattern

The most important performance optimization is using selectors with useDocumentStore. Without selectors, components re-render on every state change.

Bad vs Goodtsx
// BAD: Re-renders on ANY store change
function BadComponent() {
  const store = useDocumentStore();
  return <span>{store.currentPage}</span>;
}

// GOOD: Only re-renders when currentPage changes
function GoodComponent() {
  const currentPage = useDocumentStore((s) => s.currentPage);
  return <span>{currentPage}</span>;
}

// BETTER: Multiple values with shallow equality
import { shallow } from 'zustand/shallow';

function BetterComponent() {
  const { currentPage, totalPages } = useDocumentStore(
    (s) => ({ currentPage: s.currentPage, totalPages: s.totalPages }),
    shallow
  );
  return <span>{currentPage} / {totalPages}</span>;
}

Lazy Loading

Load the SDK only when needed to reduce initial bundle size:

Lazy Loadingtsx
import dynamic from 'next/dynamic';

// Lazy load the entire document viewer
const DocumentViewer = dynamic(
  () => import('@/components/DocumentViewer'),
  { 
    loading: () => <DocumentSkeleton />,
    ssr: false // PDF rendering requires browser APIs
  }
);

function DocumentPage({ documentId }: { documentId: string }) {
  const [showViewer, setShowViewer] = useState(false);

  return (
    <div>
      {!showViewer ? (
        <button onClick={() => setShowViewer(true)}>
          Open Document
        </button>
      ) : (
        <DocumentViewer documentId={documentId} />
      )}
    </div>
  );
}

Memoization

Memoize expensive computations and callbacks:

Memoizationtsx
import { useMemo, useCallback, memo } from 'react';

// Memoize derived data
function ProgressIndicator() {
  const fields = useDocumentStore((s) => s.fields);
  
  const progress = useMemo(() => {
    const allFields = Array.from(fields.values());
    const required = allFields.filter(f => f.definition.required);
    const completed = required.filter(f => f.value !== null);
    return required.length > 0 
      ? (completed.length / required.length) * 100 
      : 100;
  }, [fields]);

  return <ProgressBar value={progress} />;
}

// Memoize callbacks
function FieldEditor({ fieldId }: { fieldId: string }) {
  const setFieldValue = useDocumentStore((s) => s.setFieldValue);
  
  const handleChange = useCallback((value: string) => {
    setFieldValue(fieldId, { type: 'text', content: value });
  }, [fieldId, setFieldValue]);

  return <input onChange={(e) => handleChange(e.target.value)} />;
}

// Memoize components
const FieldItem = memo(function FieldItem({ field }: { field: FieldState }) {
  return (
    <div>
      <span>{field.definition.label}</span>
      <span>{field.definition.type}</span>
    </div>
  );
});

Virtual Scrolling

For documents with many pages, implement virtual scrolling to render only visible pages:

Virtual Scrollingtsx
import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualizedPageList() {
  const pages = useDocumentStore((s) => s.pages);
  const containerRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: pages.length,
    getScrollElement: () => containerRef.current,
    estimateSize: () => 800, // Estimated page height
    overscan: 1, // Render 1 page above/below viewport
  });

  return (
    <div ref={containerRef} className="h-[600px] overflow-auto">
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualItem.size}px`,
              transform: `translateY(${virtualItem.start}px)`,
            }}
          >
            <PDFPage pageNumber={virtualItem.index + 1} />
          </div>
        ))}
      </div>
    </div>
  );
}

Image Optimization

Optimize signature and uploaded images before submission:

Image Optimizationtsx
async function optimizeSignatureImage(
  imageData: string,
  maxWidth = 400,
  quality = 0.8
): Promise<string> {
  return new Promise((resolve) => {
    const img = new Image();
    img.onload = () => {
      const canvas = document.createElement('canvas');
      
      // Calculate new dimensions
      let width = img.width;
      let height = img.height;
      
      if (width > maxWidth) {
        height = (height * maxWidth) / width;
        width = maxWidth;
      }
      
      canvas.width = width;
      canvas.height = height;
      
      const ctx = canvas.getContext('2d')!;
      ctx.drawImage(img, 0, 0, width, height);
      
      // Convert to optimized PNG
      resolve(canvas.toDataURL('image/png', quality));
    };
    img.src = imageData;
  });
}

// Use before setting field value
const handleSignatureSave = async (rawImageData: string) => {
  const optimized = await optimizeSignatureImage(rawImageData);
  setFieldValue(fieldId, { type: 'signature', imageData: optimized });
};

Debouncing Input

Debounce text input to reduce state updates:

Debounced Inputtsx
import { useDebouncedCallback } from 'use-debounce';

function TextFieldInput({ fieldId }: { fieldId: string }) {
  const setFieldValue = useDocumentStore((s) => s.setFieldValue);
  const field = useDocumentStore((s) => s.fields.get(fieldId));
  
  // Local state for immediate UI feedback
  const [localValue, setLocalValue] = useState(
    (field?.value as TextFieldValue)?.content || ''
  );

  // Debounced store update
  const debouncedUpdate = useDebouncedCallback((value: string) => {
    setFieldValue(fieldId, { type: 'text', content: value });
  }, 300);

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setLocalValue(value);      // Immediate UI update
    debouncedUpdate(value);    // Debounced store update
  };

  return <input value={localValue} onChange={handleChange} />;
}

Caching

Cache document data to reduce API calls:

Document Cachingtsx
import { useQuery } from '@tanstack/react-query';

function useDocumentData(documentId: string) {
  const { apiRequest, isInitialized } = useSDK();

  return useQuery({
    queryKey: ['document', documentId],
    queryFn: () => apiRequest<DocumentWithFields>(
      `/sdk/documents/${documentId}`
    ),
    enabled: isInitialized && !!documentId,
    staleTime: 5 * 60 * 1000, // Consider fresh for 5 minutes
    cacheTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
  });
}

// Usage
function DocumentViewer({ documentId }: { documentId: string }) {
  const { data, isLoading, error } = useDocumentData(documentId);
  
  if (isLoading) return <Skeleton />;
  if (error) return <Error error={error} />;
  
  return <PDFViewer documentUrl={data.documentUrl} />;
}

Bundle Analysis

Use @next/bundle-analyzer to identify large dependencies and optimize your bundle size.

Metrics to Monitor

  • Time to First Page - How quickly the first PDF page renders
  • Field Interaction Latency - Response time when filling fields
  • Memory Usage - Especially important with large PDFs
  • Re-render Count - Use React DevTools Profiler
  • Submission Time - End-to-end submission duration

Related