Using YAML as a database for a React app
I built a game codex viewer where all the data lives in YAML files. Singleton loader with concurrent fetching, React hooks, character creator with localStorage persistence, and PDF export.
I’m building a tabletop RPG and I needed a web app to browse the game content: spells, weapons, armor, classes, skills, consumables, entities, scenarios. Hundreds of items across 8 categories. I didn’t want a backend or a database for this. The data changes when I edit YAML files in my text editor, not when users submit forms.
So I built a React 19 app where YAML files are the database. No server, no API, no build step for the data. Edit a YAML file, refresh the page, see the change. It works better than expected.
Data structure
All game content lives in YAML files organized by category:
codex/
├── sorts/ # ~100 spells, organized by type
├── armes/ # Weapons
├── equipements/ # Armor and gear
├── competences/ # Skills
├── consommables/ # Consumables
├── classes/ # Character classes (10+)
├── entites/ # NPCs and monsters
└── scenarios/ # Game scenarios
Each category has an index.yaml that lists available files. A class definition looks like this:
name: Horion
type: combat
description: "..."
base_stats:
health: 120
speed: 6
innate_resistances:
RMEC: 3
RRAD: 1
RINT: 0
flux_system:
reserve: 8
per_turn: 3
recovery: 2
affinities:
distance: 2
melee: 4
schools:
balistique: 1
martial: 4
types:
arme: 3
amelioration: 2
stats:
force: 14
dexterite: 12
constitution: 14
intelligence: 8
perception: 10
precision: 10
charisme: 8
equipment:
weapons: ["epee-longue"]
armor: ["armure-lourde"]
spells: ["charge", "riposte", "bouclier"]
The YAML files live outside the web app directory (they’re shared with the PDF rulebook build) and are symlinked into public/ so Vite serves them as static assets.
The loader: singleton with concurrent fetching
The core of the data layer is a YamlLoader singleton. One instance manages all 8 categories:
class YamlLoader {
private static instance: YamlLoader;
private yamlCache = new Map<string, any>();
private failedLoads = new Set<string>();
static getInstance(): YamlLoader {
if (!YamlLoader.instance) {
YamlLoader.instance = new YamlLoader();
}
return YamlLoader.instance;
}
}
loadAll() fetches all 8 categories in parallel:
async loadAll(): Promise<void> {
await Promise.all([
this.loadCategory('sorts'),
this.loadCategory('armes'),
this.loadCategory('equipements'),
this.loadCategory('competences'),
this.loadCategory('consommables'),
this.loadCategory('classes'),
this.loadCategory('entites'),
this.loadCategory('scenarios'),
]);
}
Each category fetches its index.yaml, then fetches every file listed in it. Individual file failures use Promise.allSettled() so one broken YAML file doesn’t take down the whole category. Failed files are tracked in a Set so we don’t retry them on subsequent loads.
The cache has two layers: the parsed YAML content (Map), and a failure tracker (Set). In dev mode (import.meta.env.DEV), the cache clears on page refresh so YAML edits show up immediately. In production, everything is cached after the first load.
React hooks
The singleton is wrapped in hooks for each category:
export function useSpells() {
const [spells, setSpells] = useState<Spell[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const loader = YamlLoader.getInstance();
loader.loadAll()
.then(() => setSpells(loader.getSpells()))
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { spells, loading, error };
}
Same pattern for useWeapons(), useClasses(), useArmors(), etc. Because the loader is a singleton, calling loadAll() from multiple components doesn’t trigger multiple fetches. The first call loads, subsequent calls return cached data.
Search is a separate hook with 300ms debounce that filters across all categories by name and description:
export function useSearch(query: string) {
const [results, setResults] = useState<SearchResults>({});
useEffect(() => {
if (!query.trim()) { setResults({}); return; }
const timer = setTimeout(() => {
setResults(YamlLoader.getInstance().searchAll(query));
}, 300);
return () => clearTimeout(timer);
}, [query]);
return { results };
}
Client-side only. At a few hundred items, the filtering is instant. If I had thousands I’d want SQLite or at least a pre-built search index, but for a game codex this is fine.
Character creator and persistence
The app has a character creator where you pick a class, customize stats with a point-buy budget, equip items from the codex, and select spells. Characters are stored in localStorage as a JSON array:
export function createCharacter(
baseData?: Partial<Character>,
sourceClassName?: string
): Character {
return {
id: uuidv4(),
createdAt: Date.now(),
updatedAt: Date.now(),
sourceClass: sourceClassName || '',
name: '',
health: 30,
speed: 5,
stats: { force: 10, dexterite: 10, constitution: 10,
intelligence: 10, perception: 10, precision: 10,
charisme: 10 },
...baseData,
};
}
importCharacter(classData) clones a class template from the codex and adds the UUID and timestamps. You start with the class defaults and customize from there. The saveCharacter function does an upsert by ID and updates the updatedAt timestamp.
There’s an auto-save hook that runs on every change with a 500ms debounce. It does JSON deep comparison to avoid unnecessary writes:
export function useAutoSave<T>(
data: T | null,
onSave: (data: T) => void,
delay: number = 500
) {
// Skip initial mount
// Deep compare via JSON.stringify
// Debounce with clearTimeout on unmount
}
PDF export
Characters export to PDF via jsPDF with the autotable plugin. The export logic:
- Filters character content based on affinity access rules (some spells require minimum affinity scores)
- Calculates final stat values (base + equipment bonuses)
- Formats everything into tables with color-coded headers (red for health, purple for flux energy, etc.)
- Handles page breaks at a 280-line threshold
- Generates the PDF for download via
file-saver
There’s also a YAML export for round-tripping: export a character to YAML, edit it in vim, import it back. This is mostly for me as the game master when I need to batch-edit 20 NPCs.
Build and routing
Vite 7 config with some specific tweaks:
export default defineConfig({
assetsInclude: ['**/*.yaml', '**/*.yml'],
base: process.env.GITHUB_ACTIONS ? '/rpg/' : '/',
server: { fs: { allow: ['..', '../..'] } },
});
assetsInclude tells Vite to treat YAML files as static assets. fs.allow lets the dev server read parent directories where the symlinked codex data lives. The base URL switches for GitHub Pages deployment.
All 20+ pages use React.lazy() with Suspense for code splitting. Routes use React Router 7 with dynamic segments for class names and character IDs. The lazy loading matters because the codex pages include large data tables and the spell page alone imports a lot of filtering/sorting logic.
Tests run with Vitest and jsdom. The test setup mocks localStorage (via vitest-localstorage-mock), matchMedia, IntersectionObserver, ResizeObserver, and URL.createObjectURL. Coverage threshold is enforced at 70% for lines, functions, branches, and statements.
The tech stack
| Package | Version | Purpose |
|---|---|---|
| React | 19.2.0 | UI |
| React Router | 7.9.5 | Routing |
| TypeScript | 5.9.3 | Types |
| Vite | 7.2.2 | Build |
| Tailwind | 3.4.18 | Styling |
| Radix UI | various | Accessible primitives |
| js-yaml | 4.1.1 | YAML parsing |
| jsPDF | 3.0.4 | PDF generation |
| Vitest | 4.0.8 | Testing |
Would I use YAML-as-database again
For content that changes through file edits and not user input, yes. You get version control for free, diffs are readable, the format is human-friendly, and there’s no server to maintain. The game designers (me and friends) edit YAML files and push to Git. The web app just reads them.
The trade-offs are real though: no query language (every filter is client-side JS), no indexing (full scan on every search), and the first page load fetches everything. For a few hundred items across 8 categories, none of these matter. For a dataset that grows to thousands of items, you’d want at minimum a pre-built search index or SQLite via sql.js.
The singleton loader pattern could also be a problem if categories were large enough to cause memory pressure. Right now the total YAML payload is small enough to hold in memory. For a bigger dataset, you’d want lazy loading per category with eviction.