DEV Community

Cover image for Carific.ai: The Last 10% - Production Hardening with React 19, Privacy Safeguards, and Memory Leak Fixes
Abdullah Jan
Abdullah Jan

Posted on

Carific.ai: The Last 10% - Production Hardening with React 19, Privacy Safeguards, and Memory Leak Fixes

Seventh post in the Carific.ai series. Previous posts: Auth System, AI Resume Analyzer, Structured AI Output, Profile Editor Type Safety, Domain Model Architecture, and Flicker-Free PDF Viewer.

Building a feature to "it works" usually takes 20% of the time. The remaining 80% is spent on the "it's bulletproof" part.

I thought Carific.ai's profile editor was feature complete. The profile editor worked, the PDF preview was smooth, and users could see the initial version of their resumes. Then code review happened.

What I discovered: memory leaks from rapid typing, React key bugs that broke focus, missing privacy safeguards for sensitive data, and accessibility gaps that screen readers couldn't handle.

This post is about the "Last 10%" - the hardening phase where we fix race conditions, implement GDPR-compliant consent flows, add array reordering with stable keys, and plug memory leaks in PDF rendering.


The Stack (Actual Versions)

{
  "next": "16.1.1",
  "react": "19.2.3",
  "@react-pdf/renderer": "4.3.1",
  "@tanstack/react-form": "1.27.1",
  "zod": "4.1.13"
}
Enter fullscreen mode Exit fullscreen mode

Chapter 1: Array Reordering with TanStack Form

Users needed to reorder resume sections move work experiences up and down, rearrange education entries. Sounds simple, but implementing it properly with TanStack Form revealed several traps.

The Feature Request

I needed to add up/down/delete buttons to every array field in the profile editor:

  • Work Experience bullets
  • Education highlights
  • Skills, Languages, Certifications
  • Projects, Achievements, Social Links

The naive approach? Add three buttons to each section and call it done. But code review caught issues I completely missed.

The DRY Violation

My first implementation copy-pasted the same button group across 8+ sections:

// ❌ Duplicated everywhere
<ButtonGroup>
  <Button onClick={() => field.moveValue(index, index - 1)}>
    <ChevronUp />
  </Button>
  <Button onClick={() => field.moveValue(index, index + 1)}>
    <ChevronDown />
  </Button>
  <Button onClick={() => field.removeValue(index)}>
    <Trash2 />
  </Button>
</ButtonGroup>
Enter fullscreen mode Exit fullscreen mode

Code review flagged this immediately: "Extract this into a reusable component."

The Solution: ArrayFieldActions Component

I created a generic component that works with any array field:

// components/form/form-components.tsx
import { useFieldContext } from "@/hooks/form-context";

export function ArrayFieldActions<T = unknown>({
  index,
  className,
}: ArrayFieldActionsProps) {
  const field = useFieldContext<T[]>();
  const total = field.state.value?.length ?? 0;

  return (
    <ButtonGroup className={className}>
      <Button
        type="button"
        variant="outline"
        size="sm"
        onClick={() => field.moveValue(index, index - 1)}
        disabled={index === 0}
        title="Move Up"
        aria-label="Move item up"
      >
        <ChevronUp className="h-4 w-4" />
      </Button>
      <Button
        type="button"
        variant="outline"
        size="sm"
        onClick={() => field.moveValue(index, index + 1)}
        disabled={index === total - 1}
        title="Move Down"
        aria-label="Move item down"
      >
        <ChevronDown className="h-4 w-4" />
      </Button>
      <Button
        type="button"
        variant="outline"
        size="sm"
        onClick={() => field.removeValue(index)}
        title="Remove"
        aria-label="Remove item"
      >
        <Trash2 className="h-4 w-4 text-destructive" />
      </Button>
    </ButtonGroup>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  1. Generic type parameter (<T = unknown>) - works with string[], WorkExperience[], any array type
  2. useFieldContext - reads the parent field automatically, no prop drilling
  3. Boundary checks - disable up/down buttons at edges
  4. Accessibility - aria-label for screen readers, title for tooltips

Now every section just uses:

<ArrayFieldActions index={index} />
Enter fullscreen mode Exit fullscreen mode

One component, 8+ sections, zero duplication.


Chapter 2: The "Index" Trap

With reordering implemented, code review caught a critical bug: I was using array indices as React keys.

The Problem

When you use an array index as a React key, React assumes the identity of a component is tied to its position. If you move "Experience A" from position 0 to 1, React thinks position 0 now holds a "new" item and position 1 is just the "old" item updated.

This leads to:

  1. Lost Focus: If you're typing in a field and reorder the list, you lose focus.
  2. State Ghosting: Local component state (like collapsed/expanded) stays at the index, not with the data.
  3. Animation Bugs: CSS transitions fire on the wrong elements.

The Bug in Action

// ❌ The trap: components/profile-editor/sections/work-experience-section.tsx
{
  field.state.value.map((_, index) => (
    <Card key={index}>
      {/* User types in position 0, clicks "Move Down" */}
      {/* React thinks position 0 is a NEW card, loses focus */}
    </Card>
  ));
}
Enter fullscreen mode Exit fullscreen mode

The Fix: Stable IDs from Factory Functions

I moved to a factory pattern where every new item gets a unique ID immediately:

// lib/validations/profile-update.ts
import { randomUUID } from "crypto";

export const createEmptyWorkExperience = (): WorkExperience => ({
  id: randomUUID(), // Generated once, never changes
  company: "",
  position: "",
  startDate: null,
  endDate: null,
  current: false,
  bullets: [],
  order: 0,
});
Enter fullscreen mode Exit fullscreen mode

Now the keys follow the data:

// ✅ The fix: using stable IDs
{
  field.state.value.map((exp) => (
    <Card key={exp.id}>
      {/* Focus stays with the data, not the position */}
    </Card>
  ));
}
Enter fullscreen mode Exit fullscreen mode

I applied this pattern to all array fields:

  • createEmptyEducation()
  • createEmptyProject()
  • createEmptySkill()
  • createEmptyLanguage()
  • createEmptyCertification()
  • createEmptyAchievement()
  • createEmptySocialLink()

The Lesson: Never use index as a key for lists that can be reordered, deleted, or inserted. Generate a UUID on the client the moment the "Add" button is clicked, and store it in your form state.


Chapter 3: Privacy Safeguards for Sensitive PII

Code review flagged a legal risk I hadn't considered: the profile editor was collecting sensitive personal information without consent or privacy notices.

The Compliance Issue

The basic info section collected:

  • Date of birth
  • Gender
  • Nationality
  • Marital status
  • Visa status

These are protected characteristics under GDPR, CCPA, and anti-discrimination laws in many jurisdictions. Collecting them without:

  1. Explicit consent
  2. Privacy notices
  3. Legitimate business purpose
  4. Proper data handling

...is a legal liability.

The Problem

Users in the US, UK, and EU are often discouraged or prohibited from including this data on resumes. But users in other regions (Middle East, Asia) may need it for visa requirements or local customs.

I needed a solution that:

  • ✅ Warns users about discrimination risks
  • ✅ Requires explicit consent before enabling fields
  • ✅ Stores consent in the database
  • ✅ Conditionally renders fields in PDF based on consent

The Implementation

Step 1: Database Schema

Added a privacyConsent field to track user consent:

// prisma/schema.prisma
model UserProfile {
  // ... other fields

  // Optional Personal Details (region-specific)
  dateOfBirth   DateTime?
  gender        String?
  nationality   String?
  maritalStatus String?
  visaStatus    String?

  privacyConsent Boolean @default(false) // NEW: tracks consent
}
Enter fullscreen mode Exit fullscreen mode

Migration:

-- prisma/migrations/20251223143639_add_privacy_consent/migration.sql
ALTER TABLE "user_profile"
ADD COLUMN "privacyConsent" BOOLEAN NOT NULL DEFAULT false;
Enter fullscreen mode Exit fullscreen mode

Step 2: Privacy Warning UI

Added an accordion with a prominent warning:

// components/profile-editor/sections/basic-info-section.tsx
import { AlertTriangle } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";

<Accordion type="single" collapsible>
  <AccordionItem value="personal-details">
    <AccordionTrigger>
      <span className="text-sm font-medium">Personal Details (Optional)</span>
    </AccordionTrigger>
    <AccordionContent>
      <Alert variant="destructive" className="mb-6">
        <AlertTriangle className="h-4 w-4" />
        <AlertTitle>Privacy & Discrimination Risk</AlertTitle>
        <AlertDescription>
          Including protected characteristics (gender, nationality, etc.) is
          discouraged or illegal in many regions (e.g., US, UK). Only include
          these if strictly required for your target location or visa
          requirements.
        </AlertDescription>
      </Alert>

      {/* Consent checkbox + fields */}
    </AccordionContent>
  </AccordionItem>
</Accordion>;
Enter fullscreen mode Exit fullscreen mode

Step 3: Consent Checkbox

Fields are disabled until the user explicitly consents:

<form.AppField name="privacyConsent">
  {(consentField) => (
    <>
      <div className="flex items-center space-x-2 mb-6 p-3 bg-muted/50 rounded-lg border">
        <Checkbox
          id="privacy-consent"
          checked={consentField.state.value}
          onCheckedChange={(checked) =>
            consentField.handleChange(checked === true)
          }
        />
        <Label htmlFor="privacy-consent" className="text-xs">
          I understand these details are optional and I consent to providing
          this sensitive information for my resume.
        </Label>
      </div>

      <div
        className={cn(
          "space-y-4 transition-opacity duration-200",
          !consentField.state.value && "opacity-50 pointer-events-none"
        )}
      >
        {/* Sensitive fields here, disabled until consent */}
        <form.AppField name="dateOfBirth">
          {(field) => (
            <field.DateField
              label="Date of Birth"
              disabled={!consentField.state.value}
            />
          )}
        </form.AppField>
        {/* ... more fields */}
      </div>
    </>
  )}
</form.AppField>
Enter fullscreen mode Exit fullscreen mode

Step 4: Conditional PDF Rendering

The resume template only shows sensitive data if consent was given:

// components/pdf/resume-template.tsx
{
  data.privacyConsent && data.dateOfBirth && (
    <Text>DOB: {formatDate(data.dateOfBirth)}</Text>
  );
}

{
  data.privacyConsent && data.nationality && (
    <Text>Nationality: {data.nationality}</Text>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Result

  • ✅ Users see a clear warning about discrimination risks
  • ✅ Fields are disabled by default
  • ✅ Consent is stored in the database
  • ✅ PDF respects user privacy choices
  • ✅ Legal compliance for GDPR/CCPA

The Lesson: When collecting sensitive PII, always implement explicit consent flows with clear warnings. Don't assume users understand the legal implications of including protected characteristics on resumes.


Chapter 4: The Rapid-Fire Memory Leak

The PDF viewer uses URL.createObjectURL(blob) to render previews. In my previous iteration, I revoked the old URL after 1 second to allow for a smooth transition.

The Bug

If a user is a fast typer, they might trigger 5 renders in 500ms. My code was only tracking the current timeout. When a new render finished, it cleared the previous timeout, but it never revoked the URL that timeout was supposed to clean up.

I was essentially abandoning blobs in the browser's memory with every keystroke.

The Breakthrough: Tracking the "Pending" URL

During code review, we realized we needed a way to revoke the "previously scheduled for revocation" URL if a new one came in early.

// components/pdf/pdf-viewer-client.tsx

const pendingRevokeUrlRef = useRef<string | null>(null);
const revokeTimeoutRef = useRef<NodeJS.Timeout | null>(null);

const handleRenderSuccess = () => {
  // 1. If there's a pending cleanup, do it NOW
  if (revokeTimeoutRef.current) {
    clearTimeout(revokeTimeoutRef.current);
    if (pendingRevokeUrlRef.current) {
      URL.revokeObjectURL(pendingRevokeUrlRef.current);
      pendingRevokeUrlRef.current = null;
    }
  }

  // 2. Schedule the next cleanup
  if (previousPdfUrl && previousPdfUrl !== pdfUrl) {
    pendingRevokeUrlRef.current = previousPdfUrl;
    revokeTimeoutRef.current = setTimeout(() => {
      if (pendingRevokeUrlRef.current) {
        URL.revokeObjectURL(pendingRevokeUrlRef.current);
        pendingRevokeUrlRef.current = null;
      }
    }, 1000);
  }
};
Enter fullscreen mode Exit fullscreen mode

The Lesson: When working with manual memory management (like Blob URLs), always think about the "Rapid Update" scenario. If your cleanup is async (via setTimeout), you must ensure that no "in-flight" cleanups get discarded.


Chapter 5: Accessibility & UX Polish

With the core functionality working, code review flagged several accessibility and UX issues.

Issue 1: Missing ARIA Labels

The reorder buttons had title attributes for tooltips, but no aria-label for screen readers:

// ❌ Screen readers can't describe these buttons
<Button title="Move Up">
  <ChevronUp />
</Button>
Enter fullscreen mode Exit fullscreen mode

The fix:

// ✅ Both visual and screen reader support
<Button title="Move Up" aria-label="Move item up">
  <ChevronUp />
</Button>
Enter fullscreen mode Exit fullscreen mode

Issue 2: End Date Fields Not Disabled

When users checked "Currently work here" or "Currently studying here", the end date field stayed enabled. Confusing UX.

The fix: watch the current field and disable endDate accordingly:

// components/profile-editor/sections/work-experience-section.tsx
{
  field.state.value.map((exp, index) => (
    <Card key={exp.id}>
      {/* ... other fields */}

      <form.AppField name={`workExperiences[${index}].current`}>
        {(currentField) => (
          <currentField.CheckboxField label="Currently work here" />
        )}
      </form.AppField>

      <form.AppField name={`workExperiences[${index}].endDate`}>
        {(dateField) => (
          <dateField.DateField
            label="End Date"
            disabled={exp.current} // Disabled when current = true
          />
        )}
      </form.AppField>
    </Card>
  ));
}
Enter fullscreen mode Exit fullscreen mode

Applied the same pattern to:

  • Education section
  • Volunteer Experience section

Issue 3: Type Safety in Form Fields

The disabled prop was added inline with type intersections:

// ❌ Inconsistent pattern
export function TextField({
  label,
  placeholder,
  disabled,
}: TextFieldProps & { disabled?: boolean }) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Code review suggested moving disabled into the interface:

// ✅ Consistent, maintainable
interface TextFieldProps extends FormControlProps {
  placeholder?: string;
  type?: "text" | "email" | "password";
  autoComplete?: string;
  disabled?: boolean; // Now part of the interface
}

export function TextField({
  label,
  placeholder,
  type = "text",
  autoComplete,
  disabled,
}: TextFieldProps) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Applied to all form field components:

  • TextFieldProps
  • TextAreaFieldProps
  • SelectFieldProps
  • DateFieldProps

The Lesson: Accessibility isn't an afterthought. Add aria-label to icon-only buttons, disable fields that shouldn't be editable, and keep type definitions consistent.


Chapter 6: The Ghost of Transitions Past

I had a 300ms CSS transition for the PDF cross-fade. But it wasn't playing. The "Previous" document just vanished instantly.

Why?

I was updating the previousPdfUrl state immediately in handleRenderSuccess. React would see the update, re-render, and since the IDs didn't match the "previous" slot anymore, the old DOM elements were unmounted instantly.

An element that is unmounted cannot animate its fade-out.

The Fix: Coordination via Delay

I had to coordinate the React state update with the CSS transition duration.

// Delay updating the state to allow for the 300ms CSS transition
setTimeout(() => {
  previousPdfUrlRef.current = pdfUrl;
  setPreviousPdfUrl(pdfUrl);
}, 300); // Perfectly matches transition-opacity duration
Enter fullscreen mode Exit fullscreen mode

The Lesson: Smooth transitions require the element to stay in the DOM for the duration of the animation. Sometimes, a tiny setTimeout in your state logic is the cleanest way to bridge the gap between "logical state" and "visual state."


TL;DR: From MVP to Production

Problem Symptoms Solution
DRY Violation Duplicated button groups everywhere Generic ArrayFieldActions<T> component
Index Keys Focus loss & state bugs on reorder Stable id generated in factory functions
Privacy Compliance Collecting PII without consent Consent checkbox + database flag + warnings
Accessibility Screen readers can't describe buttons aria-label on all icon-only buttons
Disabled State End date editable when "current" Watch current field, disable endDate
Type Safety Inconsistent disabled prop pattern Add disabled to interface, not inline
Uncaptured Refs Cleanup not running on unmount Capture values inside useEffect with refs
Rapid Blob Revocation Memory leaks during typing pendingRevokeUrlRef tracking
Instant Unmount Transitions feel "janky" Synchronize state updates with CSS durations

The Complete Commit History

For those who want to see the actual implementation:

78469bb - Add pendingRevokeUrlRef to track URLs scheduled for revocation
a9521c6 - Upgrade Next.js from 16.0.10 to 16.1.1
57bfef5 - Add revokeTimeoutRef to track pending URL revocations
f73b32b - Improve PDF viewer transition effects and fix cleanup
7d256f6 - Use item property for disabled state consistency
e1c05ca - Change Card keys from index to item.id for stable identity
7b145f9 - Add privacyConsent field to ResumeExtractionSchema
202e53c - Disable end date when current checkbox is checked
036daeb - Add privacy consent checkbox with disabled state
c456ebc - Replace type assertion with explicit default values
7bc6cbc - Add volunteer experience and hobbies sections
712977a - Refactor to use single AppField wrapper
3fb67fd - Extract ArrayFieldActions component
32de976 - Add reordering controls to all sections
405fc18 - Refactor StringArrayField to use TanStack Form helpers
2e3539e - Add reordering controls to StringArrayField
Enter fullscreen mode Exit fullscreen mode

Every line of code is in the repo: github.com/ImAbdullahJan/carific.ai

Why Open Source?

This post covers 15+ commits over several hours of work. Array reordering, privacy compliance, accessibility fixes, memory leak debugging-none of it was straightforward.

If this post (or the code in Carific.ai) saves you even 30 minutes of debugging React keys, implementing GDPR consent flows, or fixing Blob URL race conditions, then the "Build in Public" experiment is working.

The repo: github.com/ImAbdullahJan/carific.ai

If this helped you, consider starring the repo. It's the best way to support the project!


What's Next

  • 🤖 Resume Intelligence: AI-powered bullet point optimization with industry-specific feedback
  • 🎨 Theme Engine: Customizable fonts, colors, and layout templates
  • 📊 ATS Scoring: Real-time compatibility checks for applicant tracking systems
  • 🔐 Privacy Controls: Fine-grained control over which data is shared with AI models

Your Turn

I'd love feedback:

  • On array reordering: How do you handle reorderable lists in React? Any better patterns than TanStack Form's moveValue?
  • On privacy compliance: Have you implemented GDPR consent flows? What challenges did you face?
  • On memory leaks: Ever dealt with Blob URL race conditions? How did you debug them?
  • On accessibility: What accessibility tools do you use for testing?

Drop your thoughts in the comments. Let's learn together.

If this post helped you, drop a ❤️. It means more than you know.


Let's connect:

Seventh post of many. See you in the next one.

Top comments (0)