Data Persistence
Constructs use localStorage to persist data. Here's how to do it right.
Basic Pattern
Every Construct follows this pattern for data persistence:
// 1. Define a unique storage key with work-construct- prefix
const STORAGE_KEY = "work-construct-expense-tracker";
// 2. Initialize state from localStorage
const [expenses, setExpenses] = useState<Expense[]>(() => {
// Check for window to handle SSR
if (typeof window === "undefined") return [];
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
});
// 3. Save to localStorage whenever state changes
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(expenses));
}, [expenses]);Storage Key Rules
✓
Always prefix with work-construct-
work-construct-todo-list✓
Use lowercase with hyphens
work-construct-expense-tracker✓
Make it descriptive
work-construct-inventory-manager✗
Don't use generic names
data, items, stateComplex State
For Constructs with multiple pieces of state, combine them into a single object:
interface AppState {
expenses: Expense[];
categories: string[];
settings: {
currency: string;
showArchived: boolean;
};
}
const STORAGE_KEY = "work-construct-expense-tracker";
const defaultState: AppState = {
expenses: [],
categories: ["Food", "Transport", "Utilities"],
settings: {
currency: "USD",
showArchived: false,
},
};
const [state, setState] = useState<AppState>(() => {
if (typeof window === "undefined") return defaultState;
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return defaultState;
// Merge with defaults to handle schema changes
const parsed = JSON.parse(saved);
return { ...defaultState, ...parsed };
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [state]);useReducer Pattern
For complex state logic, use useReducer:
type Action =
| { type: "ADD_EXPENSE"; payload: Expense }
| { type: "DELETE_EXPENSE"; payload: string }
| { type: "UPDATE_SETTINGS"; payload: Partial<Settings> };
function reducer(state: AppState, action: Action): AppState {
switch (action.type) {
case "ADD_EXPENSE":
return {
...state,
expenses: [...state.expenses, action.payload],
};
case "DELETE_EXPENSE":
return {
...state,
expenses: state.expenses.filter((e) => e.id !== action.payload),
};
case "UPDATE_SETTINGS":
return {
...state,
settings: { ...state.settings, ...action.payload },
};
default:
return state;
}
}
// In component
const [state, dispatch] = useReducer(reducer, defaultState, (initial) => {
if (typeof window === "undefined") return initial;
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? { ...initial, ...JSON.parse(saved) } : initial;
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [state]);Data Validation
Always validate data loaded from localStorage:
function loadState(): AppState {
if (typeof window === "undefined") return defaultState;
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return defaultState;
const parsed = JSON.parse(saved);
// Validate structure
if (!Array.isArray(parsed.expenses)) {
return defaultState;
}
// Validate each item
const validExpenses = parsed.expenses.filter(
(e: unknown) =>
typeof e === "object" &&
e !== null &&
typeof (e as Expense).id === "string" &&
typeof (e as Expense).amount === "number"
);
return {
...defaultState,
...parsed,
expenses: validExpenses,
};
} catch {
// If parsing fails, return defaults
return defaultState;
}
}Storage Limits
Keep data small
localStorage has a 5MB limit per origin. Keep your data lean:
- • Don't store large files or images
- • Limit lists to reasonable sizes (e.g., 1000 items)
- • Archive old data instead of keeping everything
- • Consider implementing data cleanup
Clear Data Option
Consider adding a way for users to clear their data:
const clearAllData = () => {
if (confirm("Are you sure? This will delete all your data.")) {
localStorage.removeItem(STORAGE_KEY);
setState(defaultState);
}
};
// In settings or footer
<Button variant="destructive" onClick={clearAllData}>
Clear All Data
</Button>