This document defines the consistent patterns and conventions used across all TinyBase starter app templates to ensure uniformity and maintainability.
- PascalCase for components:
App.tsx,TodoInput.tsx,GameStatus.tsx - Store components match their purpose:
Store.tsx,SettingsStore.tsx,ChatStore.tsx
- camelCase for implementation files:
app.ts,todoInput.ts,gameStatus.ts - Store files use camelCase:
store.ts,settingsStore.ts,chatStore.ts
- Match their component's casing:
todoInput.css,gameStatus.css
- PascalCase component names:
TodoInput,Messages,Game - Store components:
Store,SettingsStore,ChatStore,CanvasStore
- camelCase with
createprefix:createTodoInput(),createMessages(),createGame() - Returns the DOM element:
HTMLDivElement,HTMLCanvasElement, etc.
// Define and export component export const App = () => { return ( <StrictMode> <Provider> {/* ... */} </Provider> </StrictMode> ); };
export const createTodoInput = (store: TodosStore): HTMLDivElement => { // ... };
Single Store Apps (todos, game):
import {createMergeableStore} from 'tinybase'; import {useCreateStore, useProvideStore /* hooks */} from 'tinybase/ui-react'; export const STORE_ID = 'todos'; // or 'game' - exported for use in components export /* re-export hooks */ {}; export const Store = () => { const store = useCreateStore(() => createMergeableStore().setTable('todos', { /* ... */ }), ); useProvideStore(STORE_ID, store); // Persistence (if enabled) {{#if persist}} {{#if persistLocalStorage}} useCreatePersister( store, (store) => createLocalPersister(store, STORE_ID), [], async (persister) => { await persister.startAutoLoad(); await persister.startAutoSave(); }, ); {{/if}} // Similar patterns for persistSqlite, persistPglite and persistTinyjoin {{/if}} // Synchronization (if enabled) {{#if sync}} // ... synchronizer setup {{/if}} return null; };
Multi-Store Apps (chat, drawing):
// SettingsStore.tsx export const STORE_ID = 'settings'; // exported for use in components export const SettingsStore = () => { const store = useCreateStore(() => createStore().setValue('username', 'Carol'), ); useProvideStore(STORE_ID, store); return null; }; // ChatStore.tsx or CanvasStore.tsx export const STORE_ID = 'chat'; // or 'canvas' - exported for use in components export const ChatStore = () => { const store = useCreateStore(() => createMergeableStore().setTable('messages', {}), ); useProvideStore(STORE_ID, store); return null; };
Single Store Apps:
import {createMergeableStore} from 'tinybase'; // Note: STORE_ID not exported in vanilla - stores are passed directly as instances export const store = createMergeableStore().setTable('todos', { /* ... */ }); // Persistence (if enabled) {{#if persist}} {{#if persistLocalStorage}} const persister = createLocalPersister(store, 'todos'); persister.startAutoLoad().then(() => persister.startAutoSave()); {{/if}} // Similar patterns for persistSqlite, persistPglite and persistTinyjoin {{/if}} // Synchronization (if enabled) {{#if sync}} // ... synchronizer setup {{/if}} export type TodosStore = typeof store; // or GameStore
Multi-Store Apps:
// settingsStore.ts // Note: STORE_ID not exported in vanilla - stores are passed directly as instances export const settingsStore = createStore().setValue('username', 'Carol'); export type SettingsStore = typeof settingsStore; // chatStore.ts or canvasStore.ts export const chatStore = createMergeableStore().setTable('messages', {}); export type ChatStore = typeof chatStore; // or CanvasStore
- Single store apps: Use descriptive name matching the app:
TodosStore,GameStore - Multi-store apps: Use descriptive names for each store:
SettingsStore,ChatStore,CanvasStore - Always export as:
export type [StoreName] = typeof store;
import {StrictMode} from 'react'; import {Provider} from 'tinybase/ui-react'; import {Inspector} from 'tinybase/ui-react-inspector'; import {Store} from './Store'; // or multiple stores import {/* UI components */} from './ComponentName'; const App = () => { return ( <StrictMode> <Provider> <Store /> {/* or <SettingsStore /><ChatStore /> */} {/* UI components */} <Inspector /> </Provider> </StrictMode> ); }; export {App};
import {store} from './store'; // or multiple stores import {createComponentName} from './componentName'; const app = (root: HTMLElement) => { const appElement = document.createElement('div'); appElement.id = 'app'; root.appendChild(appElement); appElement.appendChild(createComponentName(store)); // ... more components }; export {app};
- Import hooks from Store files (not directly from tinybase)
- Use store ID when accessing multi-store setups:
useValue('username', 'settings') - Export component with:
export {ComponentName};orexport const ComponentName = () => { ... };
- Import typed store:
import type {TodosStore} from './store'; - Components accept store as parameter:
(store: TodosStore) - Components manage their own updates via listeners
- Return DOM elements that can be appended
Pattern for self-updating vanilla components:
export const createMessages = (store: ChatStore): HTMLDivElement => { const container = document.createElement('div'); const render = () => { // Update DOM based on store state }; // Initial render render(); // Listen for changes store.addTablesListener(render); return container; };
Both React and vanilla support schema typing via conditionals:
React:
{{#if schemas}} import {createMergeableStore} from 'tinybase/with-schemas'; import * as UiReact from 'tinybase/ui-react/with-schemas'; const SCHEMA = {/* ... */} as const; type Schemas = [typeof SCHEMA, NoValuesSchema]; const {useCreateStore, useProvideStore, /* ... */} = UiReact as UiReact.WithSchemas<Schemas>; {{else}} import {createMergeableStore} from 'tinybase'; import {useCreateStore, useProvideStore, /* ... */} from 'tinybase/ui-react'; {{/if}}
Vanilla:
{{#if schemas}} import {createMergeableStore} from 'tinybase/with-schemas'; const SCHEMA = {/* ... */} as const; {{else}} import {createMergeableStore} from 'tinybase'; {{/if}} export const store = createMergeableStore(){{#if schemas}} .setTablesSchema(SCHEMA){{/if}};
When generating sortable unique IDs (e.g., for timestamp-based ordering), use HLC functions:
import {getHlcFunctions} from 'tinybase'; // Call at module level (outside components/functions) const [getNextHlc] = getHlcFunctions(); // Usage in component const id = getNextHlc(); // Generates a sortable, unique timestamp-based ID
getHlcFunctions()returns an array, use array destructuring:const [getNextHlc] = ...- Call
getHlcFunctions()at the module level (outside React components or vanilla functions) - Use for IDs that need chronological ordering (e.g., drawing strokes, messages with timestamps)
- Don't use
getUniqueId()when chronological ordering matters
// Canvas.tsx or canvas.ts import {getHlcFunctions} from 'tinybase'; const [getNextHlc] = getHlcFunctions(); // Module level export const Canvas = () => { // ... const handleStart = () => { const strokeId = getNextHlc(); // Sortable ID store.setRow('strokes', strokeId, {...}); }; };
When dealing with potentially undefined values from stores:
// Preferred: String coercion with + const username = useValue('username', STORE_ID); <Input value={username + ''} onChange={...} /> // Alternative: Nullish coalescing <Input value={username ?? ''} onChange={...} />
When retrieving typed values from stores:
// Use 'as' type assertions when type is known const username = settingsStore.getValue('username') as string; // Use nullish coalescing for optional values const points = (store.getCell('strokes', id, 'points') as string) ?? '[]';
Prefix unused parameters with underscore to indicate intentional non-use:
export const createUsernameInput = ( settingsStore: SettingsStore, _chatStore: ChatStore, // Underscore indicates intentionally unused ): HTMLDivElement => { // Only uses settingsStore };
- Use when all data belongs to one logical domain
- Examples: todos (task list), game (tic-tac-toe state)
- Store ID matches app name:
'todos','game'
- Use when data falls into distinct domains
- Examples: chat (settings + messages), drawing (settings + canvas)
- Settings store: Non-mergeable
createStore()for local preferences - Main store: Mergeable
createMergeableStore()for collaborative data
- React/external libraries
- TinyBase imports (with schema conditionals)
- Store imports
- Component imports (with
{{includeFile}}directives) - CSS imports
import {StrictMode} from 'react'; import {Provider} from 'tinybase/ui-react'; import {Inspector} from 'tinybase/ui-react-inspector'; {{includeFile template="src/todos/Store.tsx.hbs" output="src/Store.tsx"}} import {Store} from './Store'; {{includeFile template="src/todos/TodoInput.tsx.hbs" output="src/TodoInput.tsx"}} import {TodoInput} from './TodoInput';
App
├── Store
├── TodoInput
└── TodoList
└── TodoItem (repeated)
App
├── SettingsStore
├── ChatStore
├── UsernameInput
├── Messages
│ └── Message (repeated)
└── MessageInput
App
├── SettingsStore
├── CanvasStore
├── DrawingControls
│ ├── ColorPicker
│ └── BrushSize
└── Canvas
App
├── Store
└── Game
├── GameStatus
├── Board
│ └── Square (repeated)
└── Button
Persistence should be configured before synchronization to avoid race conditions.
React Pattern:
export const Store = () => { const store = useCreateStore(() => createMergeableStore()); useProvideStore(STORE_ID, store); // 1. Configure persistence FIRST useCreatePersister( store, (store) => createLocalPersister(store, STORE_ID), [], async (persister) => { await persister.startAutoLoad(); await persister.startAutoSave(); }, ); // 2. Configure synchronization SECOND useCreateSynchronizer(store, async (store) => { const synchronizer = await createWsSynchronizer(/* ... */); await synchronizer.startSync(); return synchronizer; }); return null; };
Vanilla Pattern:
export const store = createMergeableStore(); // 1. Configure persistence FIRST const persister = createLocalPersister(store, 'todos'); persister.startAutoLoad().then(() => persister.startAutoSave()); // 2. Configure synchronization SECOND createWsSynchronizer(store, new ReconnectingWebSocket(SERVER)).then( async (synchronizer) => { await synchronizer.startSync(); }, );
Local Storage (Browser):
import {createLocalPersister} from 'tinybase/persisters/persister-browser'; const persister = createLocalPersister(store, STORE_ID); await persister.startAutoLoad(); await persister.startAutoSave();
SQLite (WebAssembly):
import {createSqliteWasmPersister} from 'tinybase/persisters/persister-sqlite-wasm'; import sqlite3InitModule from '@sqlite.org/sqlite-wasm'; const sqlite3 = await sqlite3InitModule(); const db = new sqlite3.oo1.DB(':local:' + STORE_ID, 'c'); const persister = createSqliteWasmPersister(store, sqlite3, db, STORE_ID); await persister.startAutoLoad(); await persister.startAutoSave();
PGLite (PostgreSQL in Browser):
import {createPglitePersister} from 'tinybase/persisters/persister-pglite'; import {PGlite} from '@electric-sql/pglite'; const pgLite = await PGlite.create('idb://' + STORE_ID); const persister = createPglitePersister(store, pgLite, STORE_ID); await persister.startAutoLoad(); await persister.startAutoSave();
TinyJoin (Relational Database in Browser):
import {createTinyJoinPersister} from 'tinybase/persisters/persister-tinyjoin'; import {create} from 'tinyjoin'; const tinyJoin = await create('opfs://local'); const persister = createTinyJoinPersister(store, tinyJoin, STORE_ID); await persister.startAutoLoad(); await persister.startAutoSave();
In apps with multiple stores (chat, drawing), both stores get persistence:
// settingsStore - local preferences const settingsPersister = createLocalPersister(settingsStore, 'settings'); settingsPersister.startAutoLoad().then(() => settingsPersister.startAutoSave()); // chatStore - collaborative data (also gets persistence AND sync) const chatPersister = createLocalPersister(chatStore, 'chat'); chatPersister.startAutoLoad().then(() => chatPersister.startAutoSave());
When using schemas, import from the schema-aware paths:
{{#if schemas}} import {createLocalPersister} from 'tinybase/persisters/persister-browser/with-schemas'; import {useCreatePersister} from 'tinybase/ui-react/with-schemas'; {{else}} import {createLocalPersister} from 'tinybase/persisters/persister-browser'; import {useCreatePersister} from 'tinybase/ui-react'; {{/if}}
Tests are organized in the test/e2e/ directory with one file per app type:
todos.test.tschat.test.tsdrawing.test.tsgame.test.tscommon.ts(shared utilities)
Each app has three categories of tests:
- Basic Tests: Verify core functionality across all combinations
- Persistence Tests: Verify data persists after reload (sqlite, pglite, tinyjoin)
- Sync Tests: Verify data syncs between two browser windows
Define test combinations consistently:
// Basic tests: All framework/language/schema combinations const combinations = [ { language: 'javascript', framework: 'vanilla', appType: 'todos', name: 'js-vanilla-todos', }, { language: 'javascript', framework: 'react', appType: 'todos', name: 'js-react-todos', }, { language: 'typescript', framework: 'vanilla', appType: 'todos', name: 'ts-vanilla-todos', }, // ... etc ]; // Persistence tests: Only TypeScript (both vanilla and react) const persistenceCombinations = [ { language: 'typescript', framework: 'vanilla', appType: 'todos', persistenceType: 'sqlite', name: 'ts-vanilla-todos-persist-sqlite', }, { language: 'typescript', framework: 'vanilla', appType: 'todos', persistenceType: 'pglite', name: 'ts-vanilla-todos-persist-pglite', }, // ... react versions ]; // Sync tests: Only JavaScript (both vanilla and react) const syncCombinations = [ { language: 'javascript', framework: 'vanilla', appType: 'todos', name: 'js-vanilla-todos-sync', }, { language: 'javascript', framework: 'react', appType: 'todos', name: 'js-react-todos-sync', }, ];
Basic Test Function:
async function testTodosApp(page: Page) { // Test core functionality await page.waitForSelector('input[type="text"]'); await page.type('input[type="text"]', 'Test todo item'); await page.keyboard.press('Enter'); await waitForTextInPage(page, 'Test todo item'); }
Persistence Test Function:
async function testTodosPersistence(page: Page, persistenceType: string) { // 1. Perform action and capture state const testTodo = `Persisted todo ${persistenceType}`; await page.type('input[type="text"]', testTodo); await page.keyboard.press('Enter'); // 2. Wait for persistence await sleepForPersistence(persistenceType); // 3. Reload page await page.reload({waitUntil: 'domcontentloaded'}); await page.waitForFunction(() => !document.getElementById('loading')); // 4. Verify state persisted await waitForTextInPage(page, testTodo); }
Sync Test Function:
async function testTodosSync(page1: Page, page2: Page) { // 1. Perform action in page1 and verify it syncs to page2 const testTodo = 'Synced todo item'; await page1.type('input[type="text"]', testTodo); await page1.keyboard.press('Enter'); await waitForTextInPage(page1, testTodo); await page2.bringToFront(); await waitForTextInPage(page2, testTodo); // 2. (Optional) Perform bidirectional action // Check a todo in page2 and verify it syncs to page1 const checkbox = await page2.waitForSelector('input[type="checkbox"]'); await checkbox!.click(); await page1.bringToFront(); await page1.waitForFunction(() => { const cb = document.querySelector( 'input[type="checkbox"]', ) as HTMLInputElement; return cb && cb.checked; }); }
What Should Sync:
- Main data stores (todos, messages, canvas drawings, game state)
- Use
createMergeableStore()for data that syncs
What Should NOT Sync:
- Local settings stores (username in chat, brush color/size in drawing)
- Use
createStore()(non-mergeable) for local settings
Testing Non-Sync Behavior:
For apps with separate settings stores (chat, drawing), verify settings DON'T sync:
// Drawing example: Verify color/size settings don't sync async function testDrawingSync(page1: Page, page2: Page) { // Test canvas data syncs (omitted for brevity) // Verify that settings DON'T sync // Change settings in page2 await page2.waitForSelector('.colorBtn'); const colorButtons = await page2.$$('.colorBtn'); await colorButtons[1]!.click(); // Wait to ensure changes would have synced if they were going to await new Promise((resolve) => setTimeout(resolve, 1000)); // Verify page1 still has original values await page1.bringToFront(); const page1Color = await page1.evaluate(() => { const activeBtn = document.querySelector('.colorBtn.active'); return activeBtn ? (activeBtn as HTMLElement).style.background : null; }); expect(page1Color).toBe('rgb(216, 27, 96)'); // Original default color } // Chat example: Verify username fields exist (simpler check) async function testChatSync(page1: Page, page2: Page) { // Test message syncs (omitted for brevity) // Verify that username fields exist in both windows // (confirming settings store is separate and doesn't sync) const hasUsernameInput1 = await page1.evaluate(() => { return !!document.querySelector('input[placeholder*="name" i]'); }); const hasUsernameInput2 = await page2.evaluate(() => { return !!document.querySelector('input[placeholder*="name" i]'); }); expect(hasUsernameInput1).toBe(true); expect(hasUsernameInput2).toBe(true); }
Use shared functions from common.ts:
import { setupTestProject, // Creates and configures test project startDevServer, // Starts Vite dev server killProcess, // Stops dev server waitForTextInPage, // Waits for text to appear on page sleepForPersistence, // Waits appropriate time for persistence type initBrowser, // Initializes Puppeteer browser closeBrowser, // Closes Puppeteer browser setupPageErrorHandling, // Sets up error/warning capture } from './common';
Each test file follows this structure:
import {describe, test, beforeAll, afterAll, expect} from 'vitest'; import {} from /* common utilities */ './common'; // Define combinations const combinations = [/* ... */]; const persistenceCombinations = [/* ... */]; const syncCombinations = [/* ... */]; // Test functions async function testApp(page: Page) { /* ... */ } async function testPersistence(page: Page, persistenceType: string) { /* ... */ } async function testSync(page1: Page, page2: Page) { /* ... */ } // Lifecycle hooks beforeAll(async () => { await initBrowser(); }, 60000); afterAll(async () => { await closeBrowser(); }); // Test suites describe('app e2e tests', {concurrent: false}, () => { combinations.forEach((combo, index) => { test( `should create and run ${combo.name} app`, {timeout: 120000}, async () => { // Test implementation }, ); }); }); describe('app persistence e2e tests', () => { persistenceCombinations.forEach((combo, index) => { test( `should persist data with ${combo.persistenceType} in ${combo.name}`, {timeout: 120000}, async () => { // Test implementation }, ); }); }); describe('app sync e2e tests', () => { syncCombinations.forEach((combo, index) => { test( `should sync ${combo.name} between two windows`, {timeout: 120000}, async () => { // Test implementation }, ); }); });
Use consistent port offsets per app type:
- Todos:
BASE_PORT + 0-99(5173-5272) - Chat:
BASE_PORT + 100-199(5273-5372) - Drawing:
BASE_PORT + 300-399(5473-5572) - Game:
BASE_PORT + 500-599(5673-5772)
For sync tests, add combinations.length to avoid port conflicts with persistence tests.
Node Modules Handling:
Use rename() instead of cp() + rm() for instant node_modules backup/restore:
// Backup if (existsSync(nodeModulesPath)) { await rename(nodeModulesPath, nodeModulesBackup); } // Restore if (existsSync(nodeModulesBackup)) { await rename(nodeModulesBackup, nodeModulesPath); }
This provides ~73% speed improvement (e.g., 113s → 30s for chat tests).
Use workspace configuration for separate unit and e2e test settings:
// vitest.config.ts export default defineConfig({ test: { projects: [ { name: 'unit', test: {exclude: ['**/e2e/**', ...configDefaults.exclude]}, }, { name: 'e2e', test: { include: ['**/e2e/**/*.test.ts'], fileParallelism: false, // Run test files sequentially retry: 2, // Retry failed tests twice }, }, ], }, });
Add clear comments to test functions explaining:
- What action is being performed
- What is expected to sync (or not sync)
- Bidirectional sync behavior
Example:
async function testTodosSync(page1: Page, page2: Page) { // Add a todo in page1 and verify it syncs to page2 // ... // Check a todo in page2 and verify it syncs to page1 // ... }
- Consistency: React and vanilla versions should follow parallel patterns
- Self-Management: Components manage their own updates via hooks/listeners
- Type Safety: All vanilla stores export typed interfaces
- Clear Naming: Function/component names clearly indicate their purpose
- Simplicity: App files should be minimal, delegating logic to components
- Conventions: Follow established patterns unless there's a compelling reason not to
- Test Coverage: Every app has basic, persistence, and sync tests
- Test Reliability: Use proper waits, retries, and error handling for consistent test execution
- React App.tsx uses separate export statement
- Vanilla app.ts exports
{app} - Store type names are descriptive (TodosStore, GameStore, etc.)
- React components import hooks from Store files
- Vanilla components accept typed store parameters
- Vanilla components manage their own updates with listeners
- File naming matches conventions (PascalCase React, camelCase vanilla)
- Component exports follow patterns (separate export for React)
- Store IDs are string constants matching app domain
- Schema support uses conditionals consistently
- E2E tests cover basic, persistence, and sync scenarios
- Sync tests verify what syncs and what doesn't
- Multi-store apps have separate stores for settings vs data
- Settings stores use
createStore()(non-mergeable) - Data stores use
createMergeableStore()for sync support