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 and persistPglite
{{/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 and persistPglite
{{/if}}
// Synchronization (if enabled)
{{#if sync}}
// ... synchronizer setup
{{/if}}
export type TodosStore = typeof store; // or GameStoreMulti-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 = () => {
const appContainer = document.getElementById('app')!;
appContainer.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 IDgetHlcFunctions()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();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)
- 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