Skip to content

Person Type, Store, and Persistence

Before any UI, define the data shape. FamilyTree’s entire domain lives in two TypeScript interfaces and a single Pinia store.

src/types/index.ts
export interface SpouseRelationship {
personId: string
marriageDate?: string // free-form: "1995", "June 1995", "June 12, 1995"
divorceDate?: string // undefined = currently married
}
export interface Person {
id: string
name: string
birthYear?: number
deathYear?: number
bio?: string
parentIds: string[] // biological parents (max 2)
stepParentIds: string[] // step-parents (no limit)
spouses: SpouseRelationship[] // ordered; no divorceDate = current
siblingIds: string[] // explicitly added siblings
}
export type PersonFormData = Omit<Person, 'id'>

The SpouseRelationship array supports multiple marriages: divorced-and-remarried history is just multiple entries where some have divorceDate and the current one doesn’t.

src/stores/familyStore.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import type { Person, PersonFormData, SpouseRelationship } from '../types'
export const useFamilyStore = defineStore(
'family',
() => {
const people = ref<Person[]>([])
const focusPersonId = ref<string | null>(null)
const focusPerson = computed<Person | undefined>(() =>
people.value.find(p => p.id === focusPersonId.value)
)
const parents = computed<Person[]>(() =>
focusPerson.value
? people.value.filter(p => (focusPerson.value!.parentIds ?? []).includes(p.id))
: []
)
const children = computed<Person[]>(() =>
focusPerson.value
? people.value.filter(p => (p.parentIds ?? []).includes(focusPerson.value!.id))
: []
)
const currentSpouseRel = computed<SpouseRelationship | undefined>(() =>
(focusPerson.value?.spouses ?? []).find(s => !s.divorceDate)
)
const currentSpouse = computed<Person | undefined>(() =>
currentSpouseRel.value
? people.value.find(p => p.id === currentSpouseRel.value!.personId)
: undefined
)
function addPerson(data: PersonFormData): string {
const id = crypto.randomUUID()
const { parentIds, stepParentIds, siblingIds, spouses, ...rest } = data
people.value.push({
...rest,
parentIds: parentIds ?? [],
stepParentIds: stepParentIds ?? [],
spouses: spouses ?? [],
siblingIds: siblingIds ?? [],
id,
})
if (focusPersonId.value === null) focusPersonId.value = id
return id
}
function updatePerson(id: string, updates: Partial<PersonFormData>): void {
const idx = people.value.findIndex(p => p.id === id)
if (idx !== -1) people.value[idx] = { ...people.value[idx], ...updates }
}
function deletePerson(id: string): void {
people.value = people.value
.filter(p => p.id !== id)
.map(p => ({
...p,
parentIds: (p.parentIds ?? []).filter(pid => pid !== id),
stepParentIds: (p.stepParentIds ?? []).filter(pid => pid !== id),
siblingIds: (p.siblingIds ?? []).filter(sid => sid !== id),
spouses: (p.spouses ?? []).filter(s => s.personId !== id),
}))
if (focusPersonId.value === id) {
focusPersonId.value = people.value[0]?.id ?? null
}
}
function setFocus(id: string): void {
focusPersonId.value = id
}
return {
people, focusPersonId,
focusPerson, parents, children, currentSpouseRel, currentSpouse,
addPerson, updatePerson, deletePerson, setFocus,
}
},
{
persist: {
afterHydrate: (ctx) => {
ctx.store.$patch({
people: (ctx.store.people as any[]).map((p: any) => ({
parentIds: [], stepParentIds: [], spouses: [], siblingIds: [],
...p,
})),
})
},
},
}
)

When you add a new field to Person after users already have data saved in localStorage, their saved Person objects won’t have the new field. Without normalization, any code that calls .includes() on undefined.stepParentIds will throw a TypeError. The afterHydrate hook backfills missing array fields with empty arrays before any component mounts.

  1. Create src/types/index.ts with the interfaces above.
  2. Create src/stores/familyStore.ts with the store above (you can start with just addPerson, deletePerson, and setFocus).
  3. In a temporary component, call store.addPerson({ name: 'Alice', ... }), then log store.people. Confirm the person appears.
  4. Refresh the page and confirm Alice is still there (persistence working).
  • SpouseRelationship supports multiple marriages with optional marriageDate and divorceDate.
  • Person is the single source of truth — relationships are arrays of IDs, derived by computed getters.
  • afterHydrate normalizes persisted data — essential when the schema evolves after users have saved data.
  • crypto.randomUUID() generates stable, unique person IDs.