This is my fourth dev.to post. Previous posts: Auth System, AI Resume Analyzer, and Structured AI Output.
"TypeError: Cannot read properties of undefined (reading 'company')"
I stared at the error. The form worked fine with basic fields. But the moment I added dynamic arrays for work experience, education, and projects, TypeScript lost its mind.
The problem? I had types defined in three places: Prisma schema, Zod validation, and a manual types.ts file. They were already drifting apart.
This is the story of how I built a profile editor with 10+ dynamic sections, live PDF preview, and zero type duplication — using Zod as the single source of truth.
The Stack
| Package | Version | Purpose |
|---|---|---|
| Next.js | 16.0.10 | App framework |
| React | 19.2.3 | UI library |
| TanStack Form | 1.27.1 | Type-safe form state |
| Zod | 4.1.13 | Schema validation & type source |
| @react-pdf/renderer | 4.3.1 | Live PDF generation |
| date-fns | 4.1.0 | Date formatting |
| react-day-picker | 9.11.3 | Calendar component |
Chapter 1: The Type Duplication Problem
The Mess I Started With
When I first built the profile editor, I had types everywhere:
// ❌ types.ts - Manual type definitions
export interface WorkExperience {
id?: string;
company: string;
position: string;
// ... 8 more fields
}
// ❌ profile-update.ts - Zod schema (different structure!)
export const WorkExperienceSchema = z.object({
id: z.string().optional(),
company: z.string().min(1),
position: z.string().min(1),
// ... slightly different validation
});
// ❌ Prisma schema - Yet another definition
model WorkExperience {
id String @id
company String
position String
// ... database-specific fields
}
Three sources of "truth." When I added location to the Prisma schema, I forgot to add it to types.ts. The form silently dropped the field. Two hours of debugging later, I found it.
The Fix: Zod as Single Source of Truth
The solution was obvious in hindsight: derive all types from Zod schemas.
// lib/validations/profile-update.ts
// Define the schema ONCE
export const WorkExperienceSchema = z.object({
id: z.string().optional(),
company: z.string().min(1, "Company name is required"),
position: z.string().min(1, "Position is required"),
location: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
current: z.boolean().default(false),
bullets: z.array(z.string()).default([]),
});
// Derive the type FROM the schema
export type WorkExperienceInput = z.infer<typeof WorkExperienceSchema>;
Now there's exactly one place to update. Add a field to the schema, and TypeScript immediately tells you everywhere that needs updating.
I deleted the redundant types.ts file entirely. One less thing to maintain.
The Form Values Type
For the form, I needed a slightly different type — one where all optional fields have default values (empty strings, not undefined):
// Form values type - matches actual form state
export type ProfileFormValues = {
displayName: string;
headline: string;
email: string;
// ... all strings, no undefined
workExperiences: Required<WorkExperienceInput>[];
educations: Required<EducationInput>[];
// ... all arrays with Required<T>
};
This ensures the form always has values to work with. No more "cannot read property of undefined."
Chapter 2: Dynamic Array Fields That Don't Suck
The Problem with Array Forms
TanStack Form handles arrays beautifully with mode="array". But I kept writing the same boilerplate:
// ❌ Repetitive pattern in every section
<form.Field name="workExperiences" mode="array">
{(field) => (
<>
<Button onClick={() => field.pushValue(createEmpty())}>Add</Button>
{field.state.value.map((item, index) => (
<div key={item.id}>
<form.Field name={`workExperiences[${index}].company`}>
{(subField) => <Input value={subField.state.value} ... />}
</form.Field>
{/* 10 more fields... */}
</div>
))}
</>
)}
</form.Field>
The pattern was fine, but I had 10 sections: work experience, education, projects, skills, certifications, languages, achievements, social links, volunteer experience, and hobbies.
The Solution: withForm HOC + Card Headers with Item Numbers
TanStack Form's withForm higher-order component lets you create pre-typed section components. I also added card headers with item numbers for better UX — users can easily identify which experience they're editing:
// components/profile-editor/sections/work-experience-section.tsx
import { withForm } from "@/hooks/form";
import {
type ProfileFormValues,
createEmptyWorkExperience,
} from "@/lib/validations/profile-update";
export const WorkExperienceSection = withForm({
defaultValues: {} as ProfileFormValues,
render: function Render({ form }) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-md font-medium">Work Experience</h3>
<form.Field name="workExperiences" mode="array">
{(field) => (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => field.pushValue(createEmptyWorkExperience())}
>
<Plus className="h-4 w-4 mr-1" />
Add Experience
</Button>
)}
</form.Field>
</div>
<form.Field name="workExperiences" mode="array">
{(field) => (
<div className="space-y-4">
{field.state.value.map((workExperience, index) => (
<Card key={workExperience.id}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium">
Experience {index + 1}
</CardTitle>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.removeValue(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Fields here */}
</CardContent>
</Card>
))}
</div>
)}
</form.Field>
</div>
);
},
});
The key insight: defaultValues: {} as ProfileFormValues gives TypeScript the type information it needs. Now form.AppField name="workExperiences[${index}].company" is fully typed.
Factory Functions for New Items
Each array type needs a factory function that returns a complete object with all required fields:
// lib/validations/profile-update.ts
export const createEmptyWorkExperience = (): Required<WorkExperienceInput> => ({
id: crypto.randomUUID(),
company: "",
position: "",
location: "",
startDate: "",
endDate: "",
current: false,
bullets: [],
});
Using crypto.randomUUID() for IDs ensures React's key prop is stable and unique, even for items that haven't been saved to the database yet.
Chapter 3: Custom Field Components
The StringArrayField Component
Work experience bullets, education highlights, project features — they're all arrays of strings. Instead of building custom UI for each, I created a reusable StringArrayField:
// components/form/form-fields.tsx
export function StringArrayField({
label,
description,
placeholder = "Enter item...",
addButtonText = "Add Item",
}: StringArrayFieldProps) {
const field = useFieldContext<string[]>();
const items = field.state.value ?? [];
const addItem = () => {
field.handleChange([...items, ""]);
};
const removeItem = (index: number) => {
field.handleChange(items.filter((_, i) => i !== index));
};
const updateItem = (index: number, value: string) => {
const newItems = [...items];
newItems[index] = value;
field.handleChange(newItems);
};
return (
<Field>
<FieldContent>
<div className="flex items-center justify-between">
<FieldLabel>{label}</FieldLabel>
<Button type="button" variant="ghost" size="sm" onClick={addItem}>
<Plus className="h-3 w-3 mr-1" />
{addButtonText}
</Button>
</div>
</FieldContent>
<div className="space-y-2">
{items.map((item, index) => (
<div key={index} className="flex items-center gap-2">
<span className="text-muted-foreground text-xs w-5">
{index + 1}.
</span>
<Input
value={item}
onChange={(e) => updateItem(index, e.target.value)}
onBlur={field.handleBlur}
placeholder={placeholder}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeItem(index)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
{items.length === 0 && (
<p className="text-sm text-muted-foreground italic">
No items yet. Click "{addButtonText}" to add one.
</p>
)}
</div>
</Field>
);
}
Now adding bullet points to work experience is one line:
<form.AppField name={`workExperiences[${index}].bullets`}>
{(subField) => (
<subField.StringArrayField
label="Key Responsibilities & Achievements"
placeholder="Describe a responsibility or achievement..."
addButtonText="Add Bullet"
/>
)}
</form.AppField>
The DateField with shadcn Calendar
Native date inputs are ugly and inconsistent across browsers. I replaced them with shadcn's Calendar component:
// components/form/form-fields.tsx
export function DateField({
label,
description,
placeholder = "Pick a date",
}: DateFieldProps) {
const field = useFieldContext<string>();
// Convert string (yyyy-MM-dd) to Date for Calendar
const dateValue = field.state.value
? parse(field.state.value, "yyyy-MM-dd", new Date())
: undefined;
const isValidDate = dateValue && !isNaN(dateValue.getTime());
const handleSelect = (date: Date | undefined) => {
if (date) {
field.handleChange(format(date, "yyyy-MM-dd"));
} else {
field.handleChange("");
}
};
return (
<FormBase label={label} description={description}>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!field.state.value && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{isValidDate ? format(dateValue, "PPP") : placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateValue}
onSelect={handleSelect}
autoFocus
/>
</PopoverContent>
</Popover>
</FormBase>
);
}
The isValidDate check is crucial. Without it, malformed date strings (from bad data or migrations) would crash the component with "Invalid Date."
Chapter 4: Consolidating PDF Preview Components
The Duplication Problem
I had two components doing similar things:
-
ResumeViewer— A standalone PDF viewer with download -
ProfileEditor— Had its own PDF preview logic embedded
Both had duplicate code for the download button, loading states, and PDF rendering. When I fixed a bug in one, I'd forget to fix it in the other.
The Solution: Shared PDFPreview Component
I extracted the common logic into a reusable PDFPreview component:
// components/pdf/pdf-preview.tsx
const PDFViewerClient = dynamic(
() => import("./pdf-viewer-client").then((mod) => mod.PDFViewerClient),
{
ssr: false,
loading: () => (
<div
className="flex items-center justify-center bg-muted"
style={{ height: "calc(100vh - 180px)" }}
>
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
<p className="text-muted-foreground">Loading PDF viewer...</p>
</div>
),
}
);
export function PDFPreview({
profile,
title = "Resume Preview",
showDownload = true,
height = "calc(100vh - 180px)",
}: PDFPreviewProps) {
const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = async () => {
setIsDownloading(true);
try {
const blob = await pdf(<ResumeTemplate profile={profile} />).toBlob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${profile.displayName?.replace(/\s+/g, "_") || "resume"}_resume.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Resume downloaded successfully");
} catch (error) {
console.error("Failed to generate PDF:", error);
toast.error("Failed to generate PDF. Please try again.");
} finally {
setIsDownloading(false);
}
};
return (
<div className="flex flex-col gap-4 h-full">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">{title}</h2>
{showDownload && (
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={isDownloading}
>
{isDownloading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Download PDF
</>
)}
</Button>
)}
</div>
<Card className="flex-1 overflow-hidden py-1">
<CardContent className="p-0" style={{ height }}>
<PDFViewerClient profile={profile} />
</CardContent>
</Card>
</div>
);
}
Custom Download vs PDFDownloadLink
I considered using @react-pdf/renderer's built-in PDFDownloadLink component, but went with custom logic because:
- Better UX — Custom loading states and toast notifications
- Error handling — Graceful fallback when PDF generation fails
- Filename control — Dynamic filename based on user's name
- Consistent styling — Matches the rest of the UI
After consolidating, I deleted resume-viewer.tsx and moved EmptyProfileState to its own file in the profile-editor directory. One less component to maintain.
Chapter 5: Live PDF Preview (And the Bug That Almost Broke It)
The Architecture
The profile editor has a split-screen layout: form on the right, live PDF preview on the left. When you save changes, the PDF updates immediately.
// components/profile-editor/profile-editor.tsx
export function ProfileEditor({ profile }: ProfileEditorProps) {
const router = useRouter();
const form = useAppForm({
defaultValues: profileToFormValues(profile),
onSubmit: async ({ value }) => {
const response = await fetch("/api/profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(value),
});
if (!response.ok) {
throw new Error("Failed to update profile");
}
toast.success("Profile updated successfully");
router.refresh(); // Revalidate server data
},
});
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Side - PDF Preview */}
<PDFPreview profile={profile} height="calc(100vh - 180px)" />
{/* Right Side - Edit Form */}
<div className="flex flex-col gap-4">{/* Form content */}</div>
</div>
);
}
The "Eo is not a function" Bug
After implementing router.refresh() to update the PDF preview, I hit a cryptic error:
TypeError: Eo is not a function
at PDFViewerClient (components/pdf/pdf-viewer-client.tsx:15:5)
This is a known bug in @react-pdf/renderer. When the PDF document tree changes (sections added/removed), the internal reconciler crashes instead of gracefully updating. I found multiple GitHub issues describing the same problem.
The Fix: Force Remount on Data Change
The solution is to force the PDFViewer to completely remount when the profile changes, instead of trying to update in place:
// components/pdf/pdf-viewer-client.tsx
export function PDFViewerClient({ profile }: PDFViewerClientProps) {
// Use updatedAt as a key to force remount on changes
const profileKey = profile.updatedAt.toISOString();
return (
<PDFViewer
key={profileKey}
style={{
width: "100%",
height: "calc(100vh - 180px)",
border: "none",
}}
showToolbar={false}
>
<ResumeTemplate key={profileKey} profile={profile} />
</PDFViewer>
);
}
By using profile.updatedAt as the key, React treats it as a completely new component when the timestamp changes. The old PDFViewer unmounts, a fresh one mounts, and the bug never triggers.
Why router.refresh() Instead of State
My first attempt used a previewKey state variable:
// ❌ Before - stale data
const [previewKey, setPreviewKey] = useState(0);
// In onSubmit:
setPreviewKey((prev) => prev + 1);
This re-rendered the component but with stale props. The profile data came from the server, and incrementing a key didn't fetch fresh data.
// ✅ After - fresh server data
router.refresh();
router.refresh() triggers Next.js to revalidate the page's server data, ensuring the PDF preview shows the actual saved values.
Chapter 6: Production Readiness Fixes
Before shipping, I did a thorough code review and caught several issues:
1. SelectField Missing onBlur
// ❌ Before - validation errors don't show
<Select
value={field.state.value}
onValueChange={(value) => field.handleChange(value)}
>
// ✅ After - triggers validation on change
<Select
value={field.state.value}
onValueChange={(value) => {
field.handleChange(value);
field.handleBlur(); // Mark as touched
}}
>
Without handleBlur(), the field never gets marked as "touched," so validation errors don't appear until form submission. Users had no idea their selection was invalid.
2. DateField Crash on Malformed Data
// ❌ Before - crashes on invalid dates
const dateValue = parse(field.state.value, "yyyy-MM-dd", new Date());
return format(dateValue, "PPP"); // 💥 "Invalid Date"
// ✅ After - graceful fallback
const dateValue = field.state.value
? parse(field.state.value, "yyyy-MM-dd", new Date())
: undefined;
const isValidDate = dateValue && !isNaN(dateValue.getTime());
return isValidDate ? format(dateValue, "PPP") : placeholder;
Edge cases matter. Bad data from imports or migrations shouldn't crash the entire form.
3. Missing Fields in Section Components
During the review, I found that some section components were missing fields that existed in the Zod schema:
-
EducationSectionwas missing thelocationfield -
CertificationsSectionwas missingcredentialUrl
This is exactly why Zod as single source of truth matters — but you still have to render all the fields!
The Final Architecture
components/
├── form/
│ ├── form-base.tsx # Field wrapper (label, error, description)
│ ├── form-fields.tsx # TextField, DateField, StringArrayField, etc.
│ └── form-components.tsx # SubmitButton
├── pdf/
│ ├── pdf-preview.tsx # Shared wrapper with download button
│ ├── pdf-viewer-client.tsx # PDFViewer with remount fix
│ └── resume-template.tsx # ATS-compliant PDF template
└── profile-editor/
├── profile-editor.tsx # Main component with tabs
├── empty-profile-state.tsx # Shown when no profile exists
└── sections/
├── basic-info-section.tsx
├── work-experience-section.tsx
├── education-section.tsx
├── skills-section.tsx
├── projects-section.tsx
├── certifications-section.tsx
├── languages-section.tsx
├── achievements-section.tsx
└── social-links-section.tsx
lib/
└── validations/
└── profile-update.ts # Zod schemas + inferred types + factory functions
hooks/
├── form.tsx # useAppForm, withForm
└── form-context.tsx # TanStack Form contexts
TL;DR
| Problem | Solution |
|---|---|
| Type definitions in 3 places | Zod schemas as single source, z.infer<> for types |
| Repetitive array field boilerplate |
withForm HOC + reusable section components |
| String arrays (bullets, highlights) | Custom StringArrayField component |
| Ugly native date inputs | shadcn Calendar with Popover |
| Duplicate PDF preview code | Shared PDFPreview component |
| PDF crashes on data change | Force remount with key={updatedAt}
|
| Stale PDF after save |
router.refresh() for server revalidation |
| SelectField validation not showing | Add handleBlur() to onValueChange
|
| DateField crash on bad data |
isValidDate check before formatting |
Key Lessons
1. Zod is more than validation.
Using z.infer<typeof Schema> eliminates an entire category of bugs. One schema, one type, zero drift. Delete your types.ts file.
2. Factory functions prevent undefined errors.
createEmptyWorkExperience() returns a complete object with all fields. No more "cannot read property of undefined."
3. @react-pdf/renderer has quirks.
The "Eo is not a function" error is a known issue with dynamic content. Force remounting with a key prop is the workaround.
4. Consolidate duplicate code early.
I had PDF preview logic in two places. When I fixed a bug in one, I forgot the other. Extract shared components before this happens.
5. router.refresh() is powerful.
It revalidates server data without a full page reload. Perfect for updating previews after mutations.
6. Edge cases crash production.
Invalid dates, missing fields, malformed data — test with bad inputs, not just happy paths.
What's Next
- Form validation feedback — Show inline errors as users type
- Autosave — Debounced saves to prevent data loss
- Resume templates — Multiple PDF layouts to choose from
- AI-powered suggestions — Use the resume analyzer to suggest improvements inline
Why Open Source?
Every refactor, every bug fix, every "I can't believe I missed that" moment — it's all in the repo.
Building forms with dynamic arrays is hard. Building them with type safety is harder. If this post saves you from the "Eo is not a function" rabbit hole or helps you structure your Zod schemas better, it was worth writing.
The repo: github.com/ImAbdullahJan/carific.ai
⭐ If you find this useful, consider starring the repo — it helps others discover the project!
Key files from this post:
-
lib/validations/profile-update.ts— Zod schemas and factory functions -
hooks/form.tsx— TanStack Form setup with custom components -
components/form/form-fields.tsx— All reusable field components -
components/profile-editor/— The complete profile editor -
components/pdf/pdf-preview.tsx— Shared PDF preview component -
components/pdf/pdf-viewer-client.tsx— PDF viewer with remount fix
Your Turn
I'd love feedback:
- On the code: See something that could be better? Open an issue or PR.
- On the post: Too long? Missing something? Tell me.
- On forms: How do you handle complex forms with dynamic arrays?
Building in public only works if there's a public to build with.
If this post helped you, drop a ❤️. It means more than you know.
Let's connect:
- 🐙 GitHub: @ImAbdullahJan — ⭐ Star the repo if you found this helpful!
- 🐦 Twitter/X: @abdullahjan — Follow for more dev content
- 👥 LinkedIn: abdullahjan — Let's connect
Fourth post of many. See you in the next one.
Top comments (1)
It's great to see you grow!