W↓
All docs
🔑
Sign Up/Sign In
tinybase.org/demos/
Public Link
Apr 8, 2025, 12:20:33 PM - complete - 241.2 kB
Starting URLs:
https://tinybase.org/demos/
## Page: https://tinybase.org/demos/ This is a selection of demos that show how TinyBase can be used in real-world applications. ### TinyHub TinyHub is a local-first GitHub client, built in public, using TinyBase, React, and GitHub OAuth. You can try it out here. ### TinyRooms TinyRooms is a local-first app demo, using TinyBase, PartyKit, and optional GitHub OAuth. You can try it out here. ### Vite Templates You can also use Vite to start simple demo apps of your own. We provide the following templates to get started with: | Template | Language | React | Plus | | --- | --- | --- | --- | | vite-tinybase | JavaScript | No | | | vite-tinybase-ts | TypeScript | No | | | vite-tinybase-react | JavaScript | Yes | | | vite-tinybase-ts-react | TypeScript | Yes | | | vite-tinybase-ts-react-sync | TypeScript | Yes | Synchronization | | vite-tinybase-ts-react-sync-durable-object | TypeScript | Yes | Sync & Durable Objects | | vite-tinybase-ts-react-pglite | TypeScript | Yes | PGlite | | vite-tinybase-ts-react-crsqlite | TypeScript | Yes | CR-SQLite | | tinybase-ts-react-partykit | TypeScript | Yes | PartyKit | | tinybase-ts-react-electricsql | TypeScript | Yes | ElectricSQL | | expo/examples/with-tinybase | JavaScript | Yes | React Native & Expo | ### On-site Demos For the main set of TinyBase demos on this site, we start with the obligatory 'Hello World' example, and then advance from there. Some demos have multiple versions which start simple and then gain additional functionality with subsequent iterations. For a 'kitchen-sink' demo that shows TinyBase really being put through its paces, take a look at the Drawing demo. This set of demos should grow over time, so keep checking back! ## Hello World These demos demonstrate the absolute basics of using TinyBase, with an obligatory 'Hello World' example. Read more. * Hello World v1 * Hello World v2 * Hello World v3 * Hello World v4 ## Rolling Dice These demos demonstrate metrics (such as average dice rolls) and indexes (such as grouping multiple dice rolls). Read more. * Averaging Dice Rolls * Grouping Dice Rolls ## Countries In this demo, we build a simple app that uses React and a simple `Store` object to load and display country data. Read more. ## Todo App These demos demonstrate how to build a classic 'Todo' app, with successive levels of complexity. Read more. * Todo App v1 (the basics) * Todo App v2 (indexes) * Todo App v3 (persistence) * Todo App v4 (metrics) * Todo App v5 (checkpoints) * Todo App v6 (collaboration) ## Drawing In this demo, we build a more complex drawing app, using many of the features of TinyBase together. Read more. ## City Database In this demo, we build an app that loads over 140,000 records to push the size and performance limits of TinyBase. Read more. ## Car Analysis In this demo, we build an app that showcases the query capabilities of TinyBase v2.0, grouping and sorting dimensional data for lightweight analytical usage. Read more. ## Movie Database In this demo, we build an app that showcases the relational query capabilities of TinyBase v2.0, joining together information about movies, directors, and actors. Read more. ## Word Frequencies In this demo, we load the list of the 10,000 most common words in English, index them for a fast search experience, and showcase TinyBase v2.1's ability to register a `Row` in multiple `Slice` arrays of an `Index`. Read more. ## UI Components In this set of demos, we use a `Store` containing some sample data to showcase the UI components in the `ui-react-dom` module. Read more. * <ValuesInHtmlTable /> * <TableInHtmlTable /> * <SortedTableInHtmlTable /> * <SliceInHtmlTable /> * <RelationshipInHtmlTable /> * <ResultTableInHtmlTable /> * <ResultSortedTableInHtmlTable /> * <EditableValueView /> * <EditableCellView /> * <Inspector /> --- ## Page: https://tinybase.org/demos/hello-world/ These demos demonstrate the absolute basics of using TinyBase, with an obligatory 'Hello World' example. ## Hello World v1 In this demo, we set data in, and then get data from, a `Store` object. We're using keyed values (not even tabular data!), so this is about as simple as it gets. Read more. ## Hello World v2 In this demo, we again set data in, and then get data from, a `Store` object. But this time we're using tabular data. Read more. ## Hello World v3 In this demo, we set up a listener for data in the `Store` object and then change the `Cell` to see the display update. We're making changes to the Hello World v2 demo. Read more. ## Hello World v4 In this demo, we use React to render data in the `Store` object and then change a `Cell` to see the display update. We're making changes to the Hello World v3 demo. Read more. --- ## Page: https://tinybase.org/demos/rolling-dice/ These demos demonstrate metrics (such as average dice rolls) and indexes (such as grouping multiple dice rolls). ## Averaging Dice Rolls In this demo, we use a `Metrics` object to keep a count (and a rolling average) of the values in each `Cell` in a `Store`. We roll a dice 48 times and keep track of the average. Read more. ## Grouping Dice Rolls In this demo, we use an `IndexView` component to group each `Row` of the `Store` object based on the value in a `Cell` within it. We roll a dice 48 times and index the rolls by result. We're making changes to the Averaging Dice Rolls demo. Read more. --- ## Page: https://tinybase.org/demos/countries/ * TinyBase * Demos * Countries In this demo, we build a simple app that uses React and a simple `Store` object to load and display country data. ### Initialization First, we create the import aliases for TinyBase and React modules we'll need: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/persisters/persister-browser": "https://esm.sh/tinybase@6.0.1/persisters/persister-browser", "tinybase/persisters/persister-remote": "https://esm.sh/tinybase@6.0.1/persisters/persister-remote", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We're using the `Inspector` component for the purposes of seeing how the data is structured. We import the functions and components we need: import {useCallback} from 'react'; import React from 'react'; import {createRoot} from 'react-dom/client'; import {createIndexes, createStore, defaultSorter} from 'tinybase'; import { createLocalPersister, createSessionPersister, } from 'tinybase/persisters/persister-browser'; import {createRemotePersister} from 'tinybase/persisters/persister-remote'; import { CellView, IndexView, Provider, SliceView, useCell, useCreateIndexes, useCreatePersister, useCreateStore, useDelCellCallback, useSetCellCallback, useSetRowCallback, useSetValuesCallback, useSliceRowIds, useValues, } from 'tinybase/ui-react'; import {Inspector} from 'tinybase/ui-react-inspector'; We also set up some string constants for showing star emojis: const STAR = '\u2605'; const UNSTAR = '\u2606'; ### Starting The App We have a top-level `App` component, in which we initialize our data, and render the parts of the app. Firstly, we create and memoize a set of three `Store` objects with their schemas: * `countryStore` contains a list of the world's countries, loaded once from a JSON file using a remote `Persister` object. * `starStore` contains a list of the countries that the user has starred. This is persisted to the browser's local storage and starts with eight default starred countries. * `viewStore` contains the `Id` of an `Indexes` object, the `Id` of an index, and the `Id` of a slice, persisted as keyed values to session storage. These three ids represent the 'current slice' view the user is looking at and we default the app to start showing the countries starting with the letter 'A'. const App = () => { const countryStore = useCreateStore(() => createStore().setTablesSchema({ countries: {emoji: {type: 'string'}, name: {type: 'string'}}, }), ); useCreatePersister( countryStore, (store) => createRemotePersister( store, 'https://tinybase.org/assets/countries.json', ), [], async (persister) => await persister.load(), ); const starStore = useCreateStore(() => createStore().setTablesSchema({countries: {star: {type: 'boolean'}}}), ); useCreatePersister( starStore, (store) => createLocalPersister(store, 'countries/starStore'), [], async (persister) => { await persister.startAutoLoad([{ countries: { GB: {star: true}, NZ: {star: true}, AU: {star: true}, SE: {star: true}, IE: {star: true}, IN: {star: true}, BZ: {star: true}, US: {star: true}, }, }]); await persister.startAutoSave(); }, ); const viewStore = useCreateStore(() => createStore().setValuesSchema({ indexes: {type: 'string', default: 'countryIndexes'}, indexId: {type: 'string', default: 'firstLetter'}, sliceId: {type: 'string', default: 'A'}, }), ); useCreatePersister( viewStore, (store) => createSessionPersister(store, 'countries/viewStore'), [], async (persister) => { await persister.startAutoLoad(); await persister.startAutoSave(); }, ); // ... We also create and memoize two `Indexes` objects with the `useCreateIndexes` hook: * `countryIndexes` contains a single `Index` of countries in `countryStore` by their first letter, sorted alphabetically. * `starIndexes` contains a single `Index` of the countries in `starStore`. The code looks like this: // ... const countryIndexes = useCreateIndexes(countryStore, (store) => createIndexes(store).setIndexDefinition( 'firstLetter', 'countries', (getCell) => getCell('name')[0], 'name', defaultSorter, ), ); const starIndexes = useCreateIndexes(starStore, (store) => createIndexes(store).setIndexDefinition('star', 'countries', 'star'), ); // ... To start the app, we render the left-hand side `Filter` component and the main `Countries` component, wrapped in a `Provider` component that references the `Store` objects, and the `Indexes` objects: // ... return ( <Provider storesById={{countryStore, starStore, viewStore}} indexesById={{countryIndexes, starIndexes}} > <Filters /> <Countries /> <Inspector /> </Provider> ); }; We also added the `Inspector` component at the end there so you can inspect what is going on with the data during this demo. Simply click the TinyBase logo in the corner. We also use a simple grid layout to arrange the app: @accentColor: #d81b60; @spacing: 0.5rem; @border: 1px solid #ccc; @font-face { font-family: Inter; src: url(https://tinybase.org/fonts/inter.woff2) format('woff2'); } body { box-sizing: border-box; display: flex; font-family: Inter, sans-serif; letter-spacing: -0.04rem; margin: 0; height: 100vh; text-align: center; } Finally, when the window loads, we render the `App` component into the demo `div` to start the app: window.addEventListener('load', () => createRoot(document.body).render(<App />), ); ### The 'Current `Slice`' At the heart of this app is the concept of the 'current slice': at any one time, the app is displaying the countries present in a specific sliceId of a specific indexId of a specific `Indexes` object. We store these three ids in the `viewStore` as keyed values so they persist between reloads. Since both the left-hand and right-hand panels of the app need to read these parameters, we provide a custom `useCurrentSlice` hook to get those three `Cell` values out of the `viewStore`: const useCurrentSlice = () => useValues('viewStore'); When a user clicks on the letters on the left-hand side of the app, we need to write these values too. So we also provide a custom `useSetCurrentSlice` hook that provides a callback to set the three `Cell` values: const useSetCurrentSlice = (indexes, indexId, sliceId) => useSetValuesCallback( () => ({indexes, indexId, sliceId}), [indexes, indexId, sliceId], 'viewStore', ); ### The `Filters` Component This component provides the list of countries' first letters down the left-hand side of the app. We actually build this as an `IndexView` component that lists all the `sliceIds` in the `countryIndexes` index, but also add an explicit item at the top of the list to allow the user to select starred countries from the `starIndexes` index. The custom `useCurrentSlice` hook is used to get the current `Indexes` object name, current indexId, and current sliceId. We use these to determine whether a Filter is selected, and that flag is passed down as the `selected` prop to each of the child Filter components so they know whether to display themselves as selected or not. We could have each letter of the side bar listening for changes to the current slice, but in this case it is more efficient to do it once and pass down the `currentSlice` as a prop, using the `getSliceComponentProps` callback: const Filters = () => { const { indexes: currentIndexes, indexId: currentIndexId, sliceId: currentSliceId, } = useCurrentSlice(); return ( <div id="filters"> <Filter indexes="starIndexes" indexId="star" sliceId="true" label={STAR} selected={ currentIndexes == 'starIndexes' && currentIndexId == 'star' && currentSliceId == 'true' } /> <IndexView indexId="firstLetter" indexes="countryIndexes" sliceComponent={Filter} getSliceComponentProps={useCallback( (sliceId) => ({ selected: currentIndexes == 'countryIndexes' && currentIndexId == 'firstLetter' && currentSliceId == sliceId, }), [currentIndexes, currentIndexId, currentSliceId], )} /> </div> ); }; Each letter in the left hand `Filters` component is a `Filter` component, which knows which `Indexes` object the app needs to show, along with the index and slice `Ids`. This is set with the callback returned by the `useSetCurrentSlice` custom hook. For example, clicking the letter 'N' will set the current named `Indexes` object to be `countryIndexes`, the current indexId to be `firstLetter`, and the current sliceId to be 'N'. Clicking the star at the to of the list will set the current named `Indexes` object to be `starIndexes`, the current indexId to be `star`, and the current sliceId to be 'true'. The `currentSlice` prop passed down from the `Filters` component is used to decide whether to style the letter as the 'current' selection. We also display the number of countries in the slice of the relevant index. Instead of setting up a `Metrics` object to track this, it's simpler to just use the `useSliceRowIds` hook and show the `length` of the resulting array. Only the count of starred countries changes during the life of the app anyway: const Filter = ({ indexes = 'countryIndexes', indexId, sliceId, selected, label = sliceId, }) => { const handleClick = useSetCurrentSlice(indexes, indexId, sliceId); const className = 'filter' + (selected ? ' current' : ''); const rowIdCount = useSliceRowIds(indexId, sliceId, indexes).length; return ( <div className={className} onClick={handleClick}> <span className="label">{label}</span> <span className="count">{rowIdCount}</span> </div> ); }; These filters also have some straightforward styling: #filters { overflow-y: scroll; border-right: @border; padding: @spacing; .filter { cursor: pointer; &.current { color: @accentColor; } .label, .count { display: inline-block; width: 2em; } .count { color: #777; font-size: 0.8rem; text-align: left; } } } ### The `Countries` Component The main right-hand side of the app is a panel that shows the view selected with the left-hand `Filters` component. As we have seen, that component is setting the 'current slice' to be shown, comprising the name of the `Indexes` object in focus, an indexId, and a sliceId. We use those three parameters directly as the props for the `SliceView` component that forms the main part of the app: const Countries = () => ( <div id="countries"> <SliceView {...useCurrentSlice()} rowComponent={Country} /> </div> ); Each `Row` that is present in the specified slice is a country, and the `Country` component renders a small panel for each. As well as rendering the name and flag of the country (from the `countryStore` store), we also add a small 'star' at the top of each country panel. Clicking this will either call the `setStar` callback to favorite the country by adding it to the `starStore`, or it will call the `setUnstar` callback to unfavorite it and remove it again: const Country = (props) => { const {tableId, rowId} = props; const star = useCell(tableId, rowId, 'star', 'starStore'); const setStar = useSetCellCallback( tableId, rowId, 'star', () => true, [], 'starStore', ); const setUnstar = useDelCellCallback( tableId, rowId, 'star', true, 'starStore', ); const handleClick = star ? setUnstar : setStar; return ( <div className="country"> <div className="star" onClick={handleClick}> {star ? STAR : UNSTAR} </div> <div className="flag"> <CellView {...props} cellId="emoji" store="countryStore" /> </div> <div className="name"> <CellView {...props} cellId="name" store="countryStore" /> </div> </div> ); }; Removing a country from the `starStore` store rather than setting the `star` flag to false prevents the `starStore` store from growing to include all the countries that were _ever_ starred, even if no longer so. Since we are storing this in the browser, it's more efficient just to remove it. The styling for the main panel of the app is a little more complex, but we want the country cards and flags to look good! #countries { flex: 1; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: max-content; gap: @spacing; padding: @spacing * 2; overflow-y: scroll; .country { background: #fff; border: @border; padding: @spacing; position: relative; height: fit-content; .star { cursor: pointer; display: inline; left: 8px; position: absolute; top: 5px; user-select: none; } .flag { font-size: 5rem; line-height: 1em; } .name { overflow: hidden; text-overflow: ellipsis; vertical-align: top; white-space: nowrap; } } } And that's it! A simple app, all in all, but one that demonstrates using `Indexes` objects and passing down props to build a useful stateful user interface. --- ## Page: https://tinybase.org/demos/todo-app/ These demos demonstrate how to build a classic 'Todo' app, with successive levels of complexity. ## Todo App v1 (the basics) In this demo, we build a minimum viable 'Todo' app. It uses React and a simple `Store` to let people add new todos and then mark them as done. Read more. ## Todo App v2 (indexes) In this demo, we build a more complex 'Todo' app. In addition to what we built in Todo App v1 (the basics), we let people specify a type for each todo, such as 'Home', 'Work' or 'Archived'. Read more. ## Todo App v3 (persistence) In this demo, we build a yet more complex 'Todo' app, complete with persistence and a schema. Read more. ## Todo App v4 (metrics) In this version of the Todo app, we add a `Metrics` object that tracks the number of todos of each type and how many are not yet done. This allows us to show people how well they are getting through them. Read more. ## Todo App v5 (checkpoints) In this version of the Todo app, we add a `Checkpoints` object that provides us with an undo and redo stack as the main store changes. Read more. ## Todo App v6 (collaboration) In this version of the Todo app, we use a `Synchronizer` to make the application collaborative. Read more. --- ## Page: https://tinybase.org/demos/drawing/ * TinyBase * Demos * Drawing In this demo, we build a more complex drawing app, using many of the features of TinyBase together. Note that this demo does not work particularly well on mobile devices, given its use of drag and drop. Preferably try it out on a desktop browser. ### Boilerplate First, we create the import aliases for TinyBase and React modules we'll need: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/persisters/persister-browser": "https://esm.sh/tinybase@6.0.1/persisters/persister-browser", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We'll use a good selection of the TinyBase API and the `ui-react` module: import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, } from 'react'; import React from 'react'; import {createRoot} from 'react-dom/client'; import {createCheckpoints, createRelationships, createStore} from 'tinybase'; import {createLocalPersister} from 'tinybase/persisters/persister-browser'; import { LinkedRowsView, Provider, useAddRowCallback, useCell, useCreateCheckpoints, useCreatePersister, useCreateRelationships, useCreateStore, useLinkedRowIds, useLocalRowIds, useRedoInformation, useRemoteRowId, useRow, useRowListener, useSetCellCallback, useSetCheckpointCallback, useSetPartialRowCallback, useStore, useUndoInformation, } from 'tinybase/ui-react'; import {Inspector} from 'tinybase/ui-react-inspector'; The drawing app relies heavily on being able to drag and drop shapes and their resizing handles. We'll create a React hook called `useDraggableObject` that returns a `ref` that can be used to attach this behavior to component. Nothing about this is TinyBase-specific, so let's get it out of the way first. const useDraggableObject = ( getInitial, onDrag, onDragStart = null, onDragStop = null, ) => { const [start, setStart] = useState(); const handleMouseDown = useCallback( (event) => { onDragStart?.(); setStart({ x: event.clientX, y: event.clientY, initial: getInitial(), }); event.stopPropagation(); }, [getInitial, onDragStart], ); const handleMouseMove = useCallback( (event) => { if (start != null) { onDrag({ dx: event.clientX - start.x, dy: event.clientY - start.y, initial: start.initial, }); } event.stopPropagation(); }, [onDrag, start], ); const handleMouseUp = useCallback( (event) => { setStart(null); onDragStop?.(); event.stopPropagation(); }, [onDragStop], ); const ref = useRef(null); useLayoutEffect(() => { const {current} = ref; current.addEventListener('mousedown', handleMouseDown); return () => current.removeEventListener('mousedown', handleMouseDown); }, [ref, handleMouseDown]); useEffect(() => { if (start != null) { addEventListener('mousemove', handleMouseMove); addEventListener('mouseup', handleMouseUp); return () => { removeEventListener('mousemove', handleMouseMove); removeEventListener('mouseup', handleMouseUp); }; } }, [start, handleMouseMove, handleMouseUp]); return ref; }; ### Initialization We have a selection of constants we will use throughout the app. The shapes will live in a TinyBase table called `shapes`, the background canvas itself will always be shape `0`, and shapes have a minimum size and one of two valid types: const SHAPES = 'shapes'; const CANVAS_ID = '0'; const MIN_WIDTH = 50; const MIN_HEIGHT = 30; const TYPES = ['rectangle', 'ellipse']; We will use mutator listeners to ensure that the type and color of the shapes are always valid if present. These are the two functions to do that: const constrainType = (store, tableId, rowId, cellId, type) => { if (type != null && !TYPES.includes(type)) { store.setCell(tableId, rowId, cellId, TYPES[0]); } }; const constrainColor = (store, tableId, rowId, cellId, color) => { if (color != null && !/^#[a-f\d]{6}$/.test(color)) { store.setCell(tableId, rowId, cellId, '#000000'); } }; At any given time, either a single shape is selected, or none are. We will set a context for the whole app that will provide the `useState` value and setter pair for the selected `Id` across the whole app: const SelectedIdContext = createContext([null, () => {}]); const useSelectedIdState = () => useContext(SelectedIdContext); As for the application itself, we start off by initializing the store in the `useCreateStore` hook (so that it is memoized across renders), and immediately set its schema. Every shape has two pairs of coordinates, text, a type, colors, and a reference to the 'next' shape so they can be ordered in the z-index with a linked list. We also use the two mutator listeners to programmatically guarantee that types and colors are valid: const App = () => { const store = useCreateStore(() => { const store = createStore().setTablesSchema({ [SHAPES]: { x1: {type: 'number', default: 100}, y1: {type: 'number', default: 100}, x2: {type: 'number', default: 300}, y2: {type: 'number', default: 200}, text: {type: 'string', default: 'text'}, type: {type: 'string'}, backColor: {type: 'string', default: '#0077aa'}, textColor: {type: 'string', default: '#ffffff'}, nextId: {type: 'string'}, }, }); store.addCellListener(SHAPES, null, 'type', constrainType, true); store.addCellListener(SHAPES, null, 'backColor', constrainColor, true); store.addCellListener(SHAPES, null, 'textColor', constrainColor, true); return store; }, []); // ... We want an undo stack, so we create a `Checkpoints` objects for this store, again memoized with the `useCreateCheckpoints` hook. We also persist the shapes data to local browser storage, and default it to a single shape on the canvas (which is the start of the linked list of all the shapes, modelled with the `Relationships` object): // ... const checkpoints = useCreateCheckpoints(store, createCheckpoints); useCreatePersister( store, (store) => createLocalPersister(store, 'drawing/store'), [], async (persister) => { await persister.startAutoLoad([ { shapes: { [CANVAS_ID]: {x1: 0, y1: 0, nextId: '1', text: '[canvas]'}, 1: {}, }, }, ]); checkpoints?.clear(); await persister.startAutoSave(); }, [checkpoints], ); const relationships = useCreateRelationships(store, (store) => createRelationships(store).setRelationshipDefinition( 'order', SHAPES, SHAPES, 'nextId', ), ); // ... Finally we render the app, comprising a toolbar, the canvas, and the sidebar, all wrapped in a `Provider` component to make sure our top-level objects are defaulted throughout the app. The `SelectedIdContext` context provider also passes a `useState` value and setter pair into the app: // ... return ( <Provider store={store} relationships={relationships} checkpoints={checkpoints} > <SelectedIdContext.Provider value={useState()}> <Toolbar /> <Canvas /> <Sidebar /> </SelectedIdContext.Provider> <Inspector /> </Provider> ); }; We also added the `Inspector` component at the end there so you can inspect what is going on with the data during this demo. Simply click the TinyBase logo in the corner. Anyway, let's mount it into the DOM... addEventListener('load', () => createRoot(document.body).render(<App />)); ...and off we go. ### The Toolbar Component The toolbar across the top of the app contains the undo and redo buttons, the button to add a new shape - and, when a shape is selected, the stack order and delete buttons const Toolbar = () => { const [useSelectedId] = useSelectedIdState(); return ( <div id="toolbar"> <UndoRedo /> <ShapeAdd /> {useSelectedId == null ? null : ( <> <ShapeOrder /> <ShapeDelete /> </> )} </div> ); }; The undo and redo buttons use the `useUndoInformation` hook and `useRedoInformation` hook to disable or enable themselves, and handle the checkpoint moves accordingly: const UndoRedo = () => { const [canUndo, handleUndo, , undoLabel] = useUndoInformation(); const [canRedo, handleRedo, , redoLabel] = useRedoInformation(); return ( <> <div className={`button undo${canUndo ? '' : ' disabled'}`} {...(canUndo ? {onClick: handleUndo, title: `Undo ${undoLabel}`} : {})} /> <div className={`button redo${canRedo ? '' : ' disabled'}`} {...(canRedo ? {onClick: handleRedo, title: `Redo ${redoLabel}`} : {})} /> </> ); }; The button to add a new shape needs to know the current top of the z-index stack (the `frontId`) upon which a new shape will be added, implemented by adding the new shape's `Id` as the `nextId` `Cell` of the current top shape. It also selects the new shape with the `setSelectedId` function. The `useSetCheckpointCallback` hook is used to add the shape creation to the undo stack after the new shape `Row` has been added via the callback from the `useAddRowCallback` hook: const ShapeAdd = () => { const frontId = useFrontId(); const [, setSelectedId] = useSelectedIdState(); const setCheckpoint = useSetCheckpointCallback(() => 'add shape', []); const onAddRow = useCallback( (id, store) => { store.setCell(SHAPES, frontId, 'nextId', id); setSelectedId(id); setCheckpoint(); }, [frontId, setSelectedId, setCheckpoint], ); const handleClick = useAddRowCallback( SHAPES, () => ({}), [], null, onAddRow, [onAddRow], ); return ( <div className="button add" onClick={handleClick}> Add shape </div> ); }; We'll have these two hooks for getting the front and back shapes of the ordered stack. const useBackId = () => useLinkedRowIds('order', CANVAS_ID)[1]; const useFrontId = () => useLinkedRowIds('order', CANVAS_ID).slice(-1)[0]; There are four buttons for changing the shape order: move to back, move backward, move forward, and move to front. Each of these change the order of the linked list created by the `nextId` pointer in the `shapes` `Table`: const ShapeOrder = () => { const [selectedId] = useSelectedIdState(); const frontId = useFrontId(); const forwardId = useRemoteRowId('order', selectedId); const [previousId] = useLocalRowIds('order', selectedId); const [backwardId] = useLocalRowIds('order', previousId); const backId = useBackId(); return [ ['front', 'To front', frontId, useOrderShape(frontId, 'to front')], ['forward', 'Forward', frontId, useOrderShape(forwardId, 'forward')], ['backward', 'Backward', backId, useOrderShape(backwardId, 'backward')], ['back', 'To back', backId, useOrderShape(CANVAS_ID, 'to back')], ].map(([className, label, disabledIfId, handleClick]) => { const disabled = selectedId == null || selectedId == disabledIfId; return ( <div className={`button ${className} ${disabled ? ' disabled' : ''}`} onClick={disabled ? null : handleClick} key={className} > {label} </div> ); }); }; These changes are made with the `useOrderShape` hook. It batches the changes to the `nextId` `Cell` values in a single transaction, accounting for edge cases like moving a shape from the top of the stack, and sealing up the linked list after a shape has been moved: const useOrderShape = (toId, label) => { const store = useStore(); const [selectedId] = useSelectedIdState(); const [previousId] = useLocalRowIds('order', selectedId); const nextId = useRemoteRowId('order', selectedId); const nextNextId = useRemoteRowId('order', toId); const setCheckpoint = useSetCheckpointCallback(() => `move ${label}`, []); return useCallback(() => { store.transaction(() => { if (nextId != null) { store.setCell(SHAPES, previousId, 'nextId', nextId); } else { store.delCell(SHAPES, previousId, 'nextId'); } if (nextNextId != null) { store.setCell(SHAPES, selectedId, 'nextId', nextNextId); } else { store.delCell(SHAPES, selectedId, 'nextId'); } store.setCell(SHAPES, toId, 'nextId', selectedId); }); setCheckpoint(); }, [selectedId, toId, store, previousId, nextId, nextNextId, setCheckpoint]); }; The button to delete a shape needs to account for a shape being removed from the linked list (and making its next shape the previous shape's next shape instead), but otherwise it simply uses the `delRow` method to remove the record from the table. const ShapeDelete = () => { const store = useStore(); const [selectedId, setSelectedId] = useSelectedIdState(); const [previousId] = useLocalRowIds('order', selectedId); const nextId = useRemoteRowId('order', selectedId); const setCheckpoint = useSetCheckpointCallback(() => 'delete', []); const handleClick = useCallback(() => { store.transaction(() => { if (nextId == null) { store.delCell(SHAPES, previousId, 'nextId'); } else { store.setCell(SHAPES, previousId, 'nextId', nextId); } store.delRow(SHAPES, selectedId); }); setCheckpoint(); setSelectedId(); }, [store, selectedId, setSelectedId, previousId, nextId, setCheckpoint]); return ( <div className="button delete" onClick={handleClick}> Delete </div> ); }; That's the top toolbar taken care of. Try selecting, unselecting, and moving a few shapes around to get a sense for what is happening with each of these components. You can observe the resulting data structures by inspecting your local storage with your browser developer tools. The sidebar comprises controls to configure a shape's type, color and position when selected: const Sidebar = () => { const [selectedId] = useSelectedIdState(); return ( <div id="sidebar"> {selectedId == null ? null : ( <> <SidebarTypeCell /> <SidebarColorCell label="Text" cellId="textColor" /> <SidebarColorCell label="Back" cellId="backColor" /> <SidebarNumberCell label="Left" cellId="x1" /> <SidebarNumberCell label="Top" cellId="y1" /> <SidebarNumberCell label="Right" cellId="x2" /> <SidebarNumberCell label="Bottom" cellId="y2" /> </> )} </div> ); }; Each of these controls will be nested in a component to allow the CSS to lay them out correctly: const SidebarCell = ({label, children}) => ( <div className="cell"> {label}: {children} </div> ); First up, the control to configure a shape's type to be either a rectangle or an ellipse. This is a dropdown of the `TYPES` values, which when selected, fires a `setCell` method wrapped in a callback from the `useSetCellCallback` hook. The value to set the `type` `Cell` is extracted from the event's `target.value`, and the change is added to the undo stack after the change has been made, with the callback from the `useSetCheckpointCallback` hook. const SidebarTypeCell = () => { const [selectedId] = useSelectedIdState(); const setCheckpoint = useSetCheckpointCallback(() => 'change of type', []); return ( <SidebarCell label="Shape"> <select value={useCell(SHAPES, selectedId, 'type')} onChange={useSetCellCallback( SHAPES, selectedId, 'type', (e) => e.target.value, [], null, setCheckpoint, )} > {TYPES.map((type) => ( <option key={type}>{type}</option> ))} </select> </SidebarCell> ); }; Setting the shape's two colors (stored in the `textColor` and `backColor` `Cell` values) is done with the `SidebarColorCell` component. It also uses the `useSetCellCallback` hook to get a callback that in this case uses the color picker event's `target.value` - and again, the `useSetCheckpointCallback` hook to add it to the undo stack after the change has been made: const SidebarColorCell = ({label, cellId}) => { const [selectedId] = useSelectedIdState(); const setCheckpoint = useSetCheckpointCallback( () => `change of '${label.toLowerCase()}' color`, [label], ); return ( <SidebarCell label={label}> <input type="color" value={useCell(SHAPES, selectedId, cellId)} onChange={useSetCellCallback( SHAPES, selectedId, cellId, (e) => e.target.value, [], null, setCheckpoint, )} /> </SidebarCell> ); }; And finally (and similarly), the `SidebarNumberCell` component displays and lets you nudge the four numeric `x1`, `y1`, `x2`, and `y2` `Cell` values. One interesting difference here is that the `useSetCellCallback` hook is passed not an absolute number for those values, but a function that will nudge the value. const nudgeUp = (cell) => cell + 1; const nudgeDown = (cell) => cell - 1; const SidebarNumberCell = ({label, cellId}) => { const [selectedId] = useSelectedIdState(); const setCheckpoint = useSetCheckpointCallback( () => `nudge of '${label.toLowerCase()}' value`, [label], ); const handleDown = useSetCellCallback( SHAPES, selectedId, cellId, () => nudgeDown, [nudgeDown], null, setCheckpoint, ); const handleUp = useSetCellCallback( SHAPES, selectedId, cellId, () => nudgeUp, [nudgeUp], null, setCheckpoint, ); return ( <SidebarCell label={label}> <div className="spin"> <div className="button" onClick={handleDown}> - </div> {useCell(SHAPES, selectedId, cellId)} <div className="button" onClick={handleUp}> + </div> </div>{' '} </SidebarCell> ); }; ### The Canvas Component The canvas component is the main part of the application upon which the other shapes appear. Its core implementation is essentially to render a `LinkedRowsView` component using ordered view of the shapes according to the `nextId`\-based linked list. However, it also includes additional complexity arising from the fact that we enforce shapes to fit within the bounds of the canvas. This means two things: * Firstly the canvas itself attaches mutator listeners to the `Store` with the `useRowListener` hook, so that shapes can never have `x` and `y` coordinates greater than its bounds when they change. The `getShapeDimensions` function also enforces the minimum width and height constraints for each shape. * Secondly, the canvas must respond to DOM changes that affect its own size. This is done with the `updateDimensions` function, tracking the DOM with a `ResizeObserver`. `Changes` to the canvas size change both the mutator listeners for future shape moves, but also does a one-off sweep through the current objects to fit them into canvas in case it shrinks. While this may seem a little complicated to describe, it's easier to see in action. Try moving an object (or its handles) off the edge of the canvas. You'll discover that you can't, since the mutator listeners are stopping any values from exceeding the bounds. And then if you move a shape to the far-right of the canvas and shrink your browser window, you will see the smaller canvas pulling the shape in to fit. Finally, the canvas listens to the `handleMouseDown` event so that if you click anywhere on the app background, the current shape will get deselected. const Canvas = () => { const ref = useRef(null); const store = useStore(); const [canvasDimensions, setCanvasDimensions] = useState([0, 0]); const getShapeDimensions = useCallback( (id, maxX, maxY) => { const {x1, x2, y1, y2} = store.getRow(SHAPES, id); const w = Math.max(x2 - x1, Math.min(MIN_WIDTH, maxX)); const h = Math.max(y2 - y1, Math.min(MIN_HEIGHT, maxY)); return {x1, x2, y1, y2, w, h}; }, [store], ); useRowListener( SHAPES, null, (store, _tableId, rowId, getCellChange) => { const [maxX, maxY] = canvasDimensions; if (maxX == 0 || maxY == 0) { return; } const [x1Changed] = getCellChange(SHAPES, rowId, 'x1'); const [x2Changed] = getCellChange(SHAPES, rowId, 'x2'); const [y1Changed] = getCellChange(SHAPES, rowId, 'y1'); const [y2Changed] = getCellChange(SHAPES, rowId, 'y2'); if ( (x1Changed || x2Changed || y1Changed || y2Changed) && rowId != CANVAS_ID ) { const {x1, x2, y1, y2, w, h} = getShapeDimensions(rowId, maxX, maxY); if (x1Changed && x1 != null) { store.setCell( SHAPES, rowId, 'x1', between(x1, 0, Math.min(x2, maxX) - w), ); } if (x2Changed && x2 != null) { store.setCell( SHAPES, rowId, 'x2', between(x2, Math.max(x1, 0) + w, maxX), ); } if (y1Changed && y1 != null) { store.setCell( SHAPES, rowId, 'y1', between(y1, 0, Math.min(y2, maxY) - h), ); } if (y2Changed && y2 != null) { store.setCell( SHAPES, rowId, 'y2', between(y2, Math.max(y1, 0) + h, maxY), ); } } }, [...canvasDimensions, getShapeDimensions], true, ); const updateDimensions = useCallback( (current) => { const {clientWidth: maxX, clientHeight: maxY} = current; setCanvasDimensions([maxX, maxY]); store.forEachRow(SHAPES, (id) => { if (id != CANVAS_ID) { const {x2, y2, w, h} = getShapeDimensions(id, maxX, maxY); if (x2 > maxX) { store.setPartialRow(SHAPES, id, { x1: Math.max(0, maxX - w), x2: maxX, }); } if (y2 > maxY) { store.setPartialRow(SHAPES, id, { y1: Math.max(0, maxY - h), y2: maxY, }); } } }); }, [store, getShapeDimensions], ); useEffect(() => { const {current} = ref; const observer = new ResizeObserver(() => updateDimensions(current)); observer.observe(current); updateDimensions(current); return () => observer.disconnect(); }, [ref, store, updateDimensions]); const [, setSelectedId] = useSelectedIdState(); const getRowComponentProps = useCallback((id) => ({id}), []); const handleMouseDown = useCallback(() => setSelectedId(), [setSelectedId]); const backId = useBackId(); return ( <div id="canvas" onMouseDown={handleMouseDown} ref={ref}> {backId == null ? null : ( <LinkedRowsView relationshipId="order" firstRowId={backId} rowComponent={Shape} getRowComponentProps={getRowComponentProps} /> )} </div> ); }; const between = (value, min, max) => value < min ? min : value > max ? max : value; ### The Shape Component Relatively speaking, each shape is quite a simple component. It uses the `useSelectedIdState` hook to identify if it is selected (and show the `ShapeGrips` resize handles if so). It also uses the `useDraggableObject` hook we introduced at the start of the demo to make itself movable via the `handleDrag` callback. When you finish a drag, the `handleDragStop` callback records a checkpoint so that you can undo a whole dragging movement. const Shape = ({id}) => { const [selectedId, setSelectedId] = useSelectedIdState(); const selected = id == selectedId; const {x1, y1, x2, y2, backColor, type} = useRow(SHAPES, id); const store = useStore(); const getInitial = useCallback(() => store.getRow(SHAPES, id), [store, id]); const handleDrag = useSetPartialRowCallback( SHAPES, id, ({dx, dy, initial}) => ({ x1: initial.x1 + dx, y1: initial.y1 + dy, x2: initial.x2 + dx, y2: initial.y2 + dy, }), [], ); const handleDragStart = useCallback( () => setSelectedId(id), [setSelectedId, id], ); const handleDragStop = useSetCheckpointCallback(() => 'drag', []); const ref = useDraggableObject( getInitial, handleDrag, handleDragStart, handleDragStop, ); const style = { left: `${x1}px`, top: `${y1}px`, width: `${x2 - x1}px`, height: `${y2 - y1}px`, background: backColor, }; return ( <> <div ref={ref} className={`shape ${type}${selected ? ' selected' : ''}`} style={style} > <ShapeText id={id} /> </div> {selected ? <ShapeGrips id={id} /> : null} </> ); }; Inside the shape, the ShapeText component shows a text label, which, when double-clicked, sets the `editing` state to `true`, and becomes editable by turning into an `<input>` component. And once again, the `useSetCheckpointCallback` hook provides a callback to add text changes to the undo stack. const ShapeText = ({id}) => { const {text, textColor} = useRow(SHAPES, id); const ref = useRef(); const [editing, setEditing] = useState(false); const setCheckpoint = useSetCheckpointCallback(() => 'edit text', []); const handleDoubleClick = useCallback(() => setEditing(true), []); const handleBlur = useCallback(() => { setEditing(false); setCheckpoint(); }, [setCheckpoint]); const handleChange = useSetCellCallback( SHAPES, id, 'text', (e) => e.target.value, [], ); const handleKeyDown = useCallback((e) => { if (e.which == 13) { e.target.blur(); } }, []); useEffect(() => { if (editing) { ref.current.focus(); } }, [editing, ref]); const style = {color: textColor}; return editing ? ( <input ref={ref} style={style} value={text} onChange={handleChange} onKeyDown={handleKeyDown} onBlur={handleBlur} /> ) : ( <span style={style} onDoubleClick={handleDoubleClick}> {text != '' ? text : '\xa0'} </span> ); }; ### The Shape Grips When selected, a shape has eight handles for resizing it. Clockwise from top left, these adjust the `x1,y1` coordinates, the `y1` coordinate alone, the `y1,x2` coordinates, the `y2` coordinate alone, and so on. Each also has a different resizing cursor style to make it evident how they can move: const ShapeGrips = ({id}) => { const {x1, y1, x2, y2} = useRow(SHAPES, id); const xm = (x1 + x2) / 2; const ym = (y1 + y2) / 2; return ( <> <Grip m={[1, 1, 0, 0]} id={id} x={x1} y={y1} d="nwse" /> <Grip m={[0, 1, 0, 0]} id={id} x={xm} y={y1} d="ns" /> <Grip m={[0, 1, 1, 0]} id={id} x={x2} y={y1} d="nesw" /> <Grip m={[0, 0, 1, 0]} id={id} x={x2} y={ym} d="ew" /> <Grip m={[0, 0, 1, 1]} id={id} x={x2} y={y2} d="nwse" /> <Grip m={[0, 0, 0, 1]} id={id} x={xm} y={y2} d="ns" /> <Grip m={[1, 0, 0, 1]} id={id} x={x1} y={y2} d="nesw" /> <Grip m={[1, 0, 0, 0]} id={id} x={x1} y={ym} d="ew" /> </> ); }; Each grip itself is a small `<div>` element, made draggable with the `useDraggableObject` hook, and affecting the underlying shape's coordinates according to the 4-item `m` array passed in from the `ShapeGrips` component. Of course, the `useSetCheckpointCallback` hook also means that resizes get added to the undo stack: const Grip = ({m: [mx1, my1, mx2, my2], id, x, y, d}) => { const store = useStore(); const getInitial = useCallback(() => store.getRow(SHAPES, id), [store, id]); const handleDrag = useSetPartialRowCallback( SHAPES, id, ({dx, dy, initial}) => ({ x1: initial.x1 + dx * mx1, y1: initial.y1 + dy * my1, x2: initial.x2 + dx * mx2, y2: initial.y2 + dy * my2, }), [mx1, my1, mx2, my2], ); const handleDragStop = useSetCheckpointCallback(() => 'resize', []); return ( <div ref={useDraggableObject(getInitial, handleDrag, null, handleDragStop)} className="grip" style={{left: `${x}px`, top: `${y}px`, cursor: `${d}-resize`}} /> ); }; ### The Styling Finally we style the app with some LESS. This should be mostly self-explanatory, including the SVG for the button icons. @accentColor: #d81b60; @font-face { font-family: Inter; src: url(https://tinybase.org/fonts/inter.woff2) format('woff2'); } * { box-sizing: border-box; outline-color: @accentColor; } body { user-select: none; display: grid; grid-template-rows: auto 1fr; grid-template-columns: 1fr 10rem; font-family: Inter, sans-serif; letter-spacing: -0.04rem; font-size: 0.8rem; margin: 0; height: 100vh; #toolbar { z-index: 2; grid-column: span 2; background: #ddd; display: flex; align-items: center; border-bottom: 1px solid #aaa; > .button { cursor: pointer; line-height: 1rem; white-space: nowrap; border-right: 1px solid #aaa; padding: 0.5rem; &:hover { background: #ccc; } &::before { vertical-align: top; width: 1rem; height: 1rem; display: inline-block; } &.undo::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 0 100 100" stroke-width="4" stroke="black"><path fill="none" d="M25 50a42 42 0 0 1 60 0" /><path d="M14 41v20 h20z" /></svg>'); } &.redo::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 0 100 100" stroke-width="4" stroke="black"><path fill="none" d="M15 50a42 42 0 0 1 60 0" /><path d="M86 41v20 h-20z" /></svg>'); } &.add::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 0 100 100" stroke-width="4" stroke="black"><rect x="20" y="20" width="60" height="60" fill="white"/><path d="M50 30v40M30 50h40" /></svg>'); } &.front::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 0 100 100" stroke-width="4" stroke="black"><rect x="2" y="2" width="40" height="40" fill="grey"/><rect x="58" y="58" width="40" height="40" fill="grey"/><rect x="20" y="20" width="60" height="60" fill="white"/></svg>'); } &.forward::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 0 100 100" stroke-width="4" stroke="black"><rect x="11" y="11" width="60" height="60" fill="grey"/><rect x="29" y="29" width="60" height="60" fill="white"/></svg>'); } &.backward::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 0 100 100" stroke-width="4" stroke="black"><rect x="11" y="11" width="60" height="60" fill="white"/><rect x="29" y="29" width="60" height="60" fill="grey"/></svg>'); } &.back::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 0 100 100" stroke-width="4" stroke="black"><rect x="20" y="20" width="60" height="60" fill="white"/><rect x="2" y="2" width="40" height="40" fill="grey"/><rect x="58" y="58" width="40" height="40" fill="grey"/></svg>'); } &.delete::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 0 100 100" stroke-width="4" stroke="black"><rect x="20" y="20" width="60" height="60" fill="white"/><path d="M30 30l40 40M30 70l40-40" /></svg>'); } &.disabled { opacity: 0.5; cursor: default; &:hover { background: none; } } } } #sidebar { z-index: 1; background: #eee; padding: 0.5rem; border-left: 1px solid #aaa; .cell { height: 2rem; text-align: right; select, input, .spin { font: inherit; letter-spacing: -0.05rem; margin-left: 5px; width: 4.5rem; height: 1.4rem; display: inline-flex; justify-content: space-between; align-items: center; .button { cursor: pointer; border: 1px solid #aaa; font: inherit; width: 1rem; text-align: center; &:hover { background: #ccc; } } } } } #canvas { position: relative; z-index: 0; .shape { align-items: center; background: white; border: 1px solid black; box-sizing: border-box; display: flex; justify-content: center; position: absolute; overflow: hidden; white-space: nowrap; z-index: 1; &.ellipse { border-radius: 50%; } &.selected { cursor: move; } input, span { background: transparent; border: none; font: inherit; width: 100%; text-align: center; margin: 0.5rem; text-overflow: ellipsis; overflow: hidden; } } .grip { background: white; border: 1px solid @accentColor; box-sizing: border-box; height: 6px; margin: -3px; position: absolute; width: 6px; z-index: 2; } } } ### That's It! That's the drawing app. Admittedly, there was a lot going on in here: a demonstration of a `Relationships`\-based linked list, an undo stack, plenty of callbacks, and React hooks galore. On the other hand, we have a fast, and functional drawing application that makes the most of the structured data store underneath. Hopefully that all made sense. --- ## Page: https://tinybase.org/demos/city-database/ In this demo, we build an app that loads over 140,000 records to push the size and performance limits of TinyBase. We use Opendatasoft GeoNames as the source of the information in this app. Thank you for a great data set to demonstrate TinyBase! ### Boilerplate First, we create the import aliases for TinyBase and React modules we'll need: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "tinybase/ui-react-dom": "https://esm.sh/tinybase@6.0.1/ui-react-dom", "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We're using the `Inspector` component for the purposes of seeing how the data is structured. We need the following parts of the TinyBase API, the `ui-react` module, and React itself: import {useMemo, useState} from 'react'; import React from 'react'; import {createRoot} from 'react-dom/client'; import {createQueries, createStore} from 'tinybase'; import {Provider, useCreateStore} from 'tinybase/ui-react'; import {SortedTableInHtmlTable} from 'tinybase/ui-react-dom'; import {Inspector} from 'tinybase/ui-react-inspector'; ### Initializing The Application In the main part of the application, we initialize a default `Store` (called `store`) that contains a single `Table` of cities. The `Store` object is memoized by the useCreateStore method so it only created the first time the app is rendered. const App = () => { const store = useCreateStore(createStore); // ... This application depends on loading data into the main `Store` the first time it is rendered. We do this by having an `isLoading` flag in the application's state, and setting it to `false` only once the asynchronous loading sequence in the (soon-to-be described) `loadCities` function has completed. Until then, a loading spinner is shown. // ... const [isLoading, setIsLoading] = useState(true); useMemo(async () => { await loadCities(store); setIsLoading(false); }, []); return ( <Provider store={store}> {isLoading ? <Loading /> : <Body />} <Inspector /> </Provider> ); } We added the `Inspector` component at the end there so you can inspect what is going on with the data during this demo. Simply click the TinyBase logo in the corner. With simple boilerplate code to load the component, off we go: addEventListener('load', () => createRoot(document.body).render(<App />)); ### Loading Spinner Let's quickly dispatch with the loading spinner, a plain element with some CSS. const Loading = () => <div id="loading" />; This is styled as a 270° arc with a spinning animation: #loading { animation: spin 1s infinite linear; height: 2rem; margin: 40vh auto; width: 2rem; &::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="2rem" viewBox="0 0 100 100"><path d="M50 10A40 40 0 1 1 10 50" stroke="black" fill="none" stroke-width="4" /></svg>'); } } @keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } } ### Main Body The main body of the application is shown once the loading has completed and the spinner has disappeared. It simply contains the city table. const Body = () => { return ( <main> <CityTable /> </main> ); }; Again, this component has minimal styling: main { padding: 0.5rem; } ### Loading The Data The city data for the application has been converted into a tab-separated variable format. TSV files are smaller and faster than JSON to load over the wire. We extract the column names from the top of the TSV, coerce numeric `Cell` values, and load everything into a standard `Table` called `cities`. Everything is wrapped in a transaction for performance. const NUMERIC = /^[\d\.-]+$/; const loadCities = async (store) => { const rows = ( await (await fetch(`https://tinybase.org/assets/cities.tsv`)).text() ).split('\n'); const cellIds = rows.shift().split('\t'); store.transaction(() => rows.forEach((row, rowId) => row .split('\t') .forEach((cell, c) => store.setCell( 'cities', rowId, cellIds[c], NUMERIC.test(cell) ? parseFloat(cell) : cell, ), ), ), ); }; `loadCities` was the function referenced in the main `App` component, so once this completes, the data is loaded and we're ready to go. Finally, since the structure of the `Table` is well known, we create a constant list of column names for use when rendering: const CUSTOM_CELLS = [ 'Name', 'Country', 'Population', 'Latitude', 'Longitude', 'Elevation', ]; Now let's render this data! ### The `CityTable` Component This is the component that renders city data in a table. Previously there was a whole table implementation in this demo, but as of TinyBase v4.1, we just use the `SortedTableInHtmlTable` component from the new `ui-react-dom` module straight out of the box! const CityTable = () => ( <SortedTableInHtmlTable tableId="cities" cellId="Population" descending={true} limit={10} sortOnClick={true} paginator={true} customCells={CUSTOM_CELLS} idColumn={false} /> ); In other words, it starts off sorting cities by population in descending order, has interactive column headings to change the sorting, and has a paginator for going through cities in pages of ten. The table benefits from some light styling for the pagination buttons and the table itself: table { border-collapse: collapse; font-size: inherit; line-height: inherit; margin-top: 0.5rem; table-layout: fixed; width: 100%; caption { text-align: left; button { border: 0; margin-right: 0.25rem; } } th, td { overflow: hidden; padding: 0.15rem 0.5rem 0.15rem 0; white-space: nowrap; } th { border: solid #ddd; border-width: 1px 0; cursor: pointer; text-align: left; width: 15%; &:nth-child(1) { width: 25%; } } td { border-bottom: 1px solid #eee; } } That's it for the components. ### Default Styling We finish off with the default CSS styling and typography that the app uses: @font-face { font-family: Inter; src: url(https://tinybase.org/fonts/inter.woff2) format('woff2'); } * { box-sizing: border-box; } body { user-select: none; font-family: Inter, sans-serif; letter-spacing: -0.04rem; font-size: 0.8rem; line-height: 1.5rem; margin: 0; color: #333; } ### Conclusion When run, you will see the spinner while the application loads the data. The time taken will depend to a large degree on your network connection since the data is 5 megabytes or so. But once loaded, the app remains reasonably responsive. Even on a slow device, sorting by a high cardinality string column (such as name) typically takes well less than a second. A high cardinality numeric column (such as population) takes a few hundred milliseconds. Low cardinality columns (like country) are even faster - and pagination is also sub-hundred milliseconds. --- ## Page: https://tinybase.org/demos/car-analysis/ In this demo, we build an app that showcases the query capabilities of TinyBase v2.0, grouping and sorting dimensional data for lightweight analytical usage. The data from this demo is derived from `cars.json` in the Vega datasets - thank you UW Interactive Data Lab! ### An Overview Of The Data Before looking at code, let's familiarize ourselves with the data used in this application. The raw data is loaded from a TSV file into one single `Table` object: `cars`, and comprises almost 400 records of cars made in the 1970s and 1980s. For each, the data includes the manufacturer, the car name, year, and region. These `Cell` values are 'dimensions' with which the data can be grouped. Each record also includes a number of quantitative fields, including the car's miles-per-gallon (MPG), the number of cylinders, their displacement, its horsepower, weight, and acceleration. These `Cell` values are 'measures' which can be aggregated together - in this basic app, to find their average, maximum, or minimum. The app is oriented around one single query. As the user picks different dimensions or measures in the app's sidebar, that query is re-run and the results (either in graphical or tabular form) reactively update immediately. ### Boilerplate First, we create the import aliases for TinyBase and React modules we'll need: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "tinybase/ui-react-dom": "https://esm.sh/tinybase@6.0.1/ui-react-dom", "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We're using the `Inspector` component for the purposes of seeing how the data is structured. We need the following parts of the TinyBase API, the `ui-react` module, and React itself: import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import React from 'react'; import {createRoot} from 'react-dom/client'; import {createQueries, createStore} from 'tinybase'; import { Provider, useCreateQueries, useCreateStore, useQueries, useResultCell, useResultSortedRowIds, useResultTable, } from 'tinybase/ui-react'; import {ResultSortedTableInHtmlTable} from 'tinybase/ui-react-dom'; import {Inspector} from 'tinybase/ui-react-inspector'; For simplicity, we set up a few convenience arrays that distinguish the columns present in the data. In a more comprehensive app, these could certainly be programmatically determined. const DIMENSION_CELL_IDS = ['Manufacturer', 'Name', 'Year', 'Region']; const MEASURE_CELL_IDS = [ 'MPG', 'Cylinders', 'Displacement', 'Horsepower', 'Weight', 'Acceleration', ]; We also set up the list of aggregations that are available in the user interface: const AGGREGATES = { Maximum: 'max', Average: 'avg', Minimum: 'min', }; We set up some constants that we'll use in the app to help arrange the graph elements, and to detect numeric values in the imported files so that they can added to the `Store` as numbers: const GRAPH_FONT = 11; const GRAPH_PADDING = 5; const NUMERIC = /^[\d\.]+$/; And finally, while we are here, we create a handy little function to round down numbers to two significant figures. Nothing clever but sufficient for this basic application. const round = (value) => Math.round(value * 100) / 100; ### Initializing The Application In the main part of the application, we want to initialize a default `Store` (called `store`) and a default `Queries` object. At this point, we are not configuring any queries yet. The two `Store` objects and the `Queries` object are memoized by the useCreateStore method and useCreateQueries method so they are only created the first time the app is rendered. const App = () => { const store = useCreateStore(createStore); const queries = useCreateQueries(store, createQueries); // ... This application depends on loading data into the main `Store` the first time it is rendered. We do this by having an `isLoading` flag in the application's state, and setting it to `false` only once the asynchronous loading sequence in the (soon-to-be described) `loadStore` function has completed. Until then, a loading spinner is shown. // ... const [isLoading, setIsLoading] = useState(true); useMemo(async () => { await loadTable(store); setIsLoading(false); }, []); return ( <Provider store={store} queries={queries}> {isLoading ? <Loading /> : <Body />} <Inspector /> </Provider> ); } We also added the `Inspector` component at the end there so you can inspect what is going on with the data during this demo. Simply click the TinyBase logo in the corner. The loading spinner itself is a plain element with some CSS. const Loading = () => <div id="loading" />; This is styled as a 270° arc with a spinning animation: #loading { animation: spin 1s infinite linear; height: 2rem; margin: 40vh auto; width: 2rem; &::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="2rem" viewBox="0 0 100 100"><path d="M50 10A40 40 0 1 1 10 50" stroke="black" fill="none" stroke-width="4" /></svg>'); } } @keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } } And finally this simple boilerplate code loads the main `App` component to start things off: addEventListener('load', () => createRoot(document.body).render(<App />)); ### Loading The Data The car data has been converted into a tab-separated variable format. TSV files are smaller and faster than JSON to load over the wire. In this loading function, we extract the column names from the top of the TSV, check each row has a matching cardinality, use the first column as the `Row` `Id`, coerce numeric `Cell` values, and load everything into a standard `Table`. const loadTable = async (store) => { const rows = ( await (await fetch(`https://tinybase.org/assets/cars.tsv`)).text() ).split('\n'); const cellIds = rows.shift().split('\t'); store.transaction(() => rows.forEach((row, rowId) => { const cells = row.split('\t'); if (cells.length == cellIds.length) { cells.forEach((cell, c) => { if (cell != '') { if (NUMERIC.test(cell)) { cell = parseFloat(cell); } store.setCell('cars', rowId, cellIds[c], cell); } }); } }), ); }; Remember, `loadStore` was the function referenced in the main `App` component, so once this completes, the data is loaded and we're ready to go. ### The `Body` Component The user interface of the application has two modes: a graphical rendering of aggregated data, and a tabular one. In both cases there is a sidebar down the left hand side that allows the user to select dimensions, measures, and the aggregation to be used. The `Body` component wraps the sidebar and the field selection state, and then the `ResultGraph` and `[`ResultTable`](/api/queries/type-aliases/result/resulttable/)` components render the two modes. The state of the application is based on selected dimensions and measures, which aggregate is used, and whether the data is rendered as a table. This is set up with the following hooks: const Body = () => { const [dimensions, setDimensions] = useState(['Manufacturer']); const [measures, setMeasures] = useState(['MPG', 'Horsepower']); const [aggregate, setAggregate] = useState('Average'); const [showTable, setAsGraph] = useState(false); // ... The first three of these state variables - `dimensions`, `measures`, and `aggregate` - are used to create a query from the `cars` `Table`, and group the fields accordingly. We run the memoized creation of the query in a separate hook (which we'll come to shortly), and get its `Id` so we can use it in the UI: const queryId = useBuildQuery(dimensions, measures, aggregate); Here we render the left-hand sidebar, showing the available dimensions, measures, and aggregates. We also show a toggle for the tabular view, and offer a link to the source of the data. (We'll also cover the simple `[`Select`](/api/queries/type-aliases/definition/select/)` component later.) return ( <> <aside> <b>Dimensions</b> <Select options={DIMENSION_CELL_IDS} selected={dimensions} onOptionsChange={setDimensions} /> <hr /> <b>Measures</b> <Select options={MEASURE_CELL_IDS} selected={measures} onOptionsChange={setMeasures} /> <hr /> <b>Aggregate</b> <Select options={Object.keys(AGGREGATES)} selected={[aggregate]} onOptionsChange={setAggregate} multiple={false} /> <hr /> <input id="showTable" type="checkbox" checked={showTable} onChange={useCallback(({target}) => setAsGraph(target.checked), [])} /> <label for="showTable">Show table</label> <br /> <small> <a href="https://github.com/vega/vega-datasets/blob/next/data/cars.json"> Source </a> </small> </aside> {/* ... */} We complete the `Body` component with a simple toggle between the two main views, which of course we will also shortly explore in detail. Both take the query `Id` and the columns to display: {/* ... */} {showTable ? ( <ResultTable queryId={queryId} columns={[...dimensions, ...measures]} /> ) : ( <ResultGraph queryId={queryId} dimensions={dimensions} measures={measures} /> )} </> ); }; That's the main outer structure of the application, but a lot of the magic is in the `useBuildQuery` hook we mentioned earlier. Whenever the dimensions, measures, or aggregate selections change, we want to run a new query on the data accordingly. Here's the implementation: const useBuildQuery = (dimensions, measures, aggregate) => useMemo(() => { useQueries().setQueryDefinition('query', 'cars', ({select, group}) => { dimensions.forEach((cellId) => select(cellId)); measures.forEach((cellId) => { select(cellId); group(cellId, AGGREGATES[aggregate]); }); }); return 'query'; }, [dimensions, measures, aggregate]); The whole hook is memoized so that the query is not run every time the `Body` component is run. The query is (imaginatively) called `query`, and is against the `cars` `Table` in the main `Store`. The query simply selects every selected dimension, and every selected measure. Each measure is then grouped with the selected aggregate. And that's it! Enough to query the dataset such that both the graphical and tabular views work. We'll dive into _their_ implementations next. The styling for the main part of the application is simple: body { display: flex; height: 100vh; } aside { background: #ddd; flex: 0; padding: 0.5rem 0.5rem 0; } main { background: #fff; flex: 1; max-height: 100vh; padding: 0.5rem; } input { height: 1.5rem; margin: 0 0.25rem 0 0; vertical-align: bottom; } hr { margin: 0.5rem 0 0.1rem; } ### The `[`ResultTable`](/api/queries/type-aliases/result/resulttable/)` Component We start with the tabular view since it's a little simpler than the graph. There's a slightly more generalized version of this component described in the TinyMovies demo. This one is quite simple. Previously there was a whole table implementation in this demo, but as of TinyBase v4.1, we just use the `ResultSortedTableInHtmlTable` component from the new `ui-react-dom` module straight out of the box. The only extra step is transforming the array of selected columns into the customCells prop to render. const ResultTable = ({queryId, columns}) => ( <ResultSortedTableInHtmlTable queryId={queryId} sortOnClick={true} paginator={true} limit={10} idColumn={false} customCells={useMemo( () => Object.fromEntries( columns.map((column) => [column, {component: CustomCell}]), ), [...columns], )} /> ); We're using a slightly custom component for the table cells. If a `Cell` has a numeric value, we crudely round it to two decimal places. Also we give it the cellId as className so we can color the left-hand border. // ... const CustomCell = ({queryId, rowId, cellId}) => { const cell = useResultCell(queryId, rowId, cellId); return ( <span className={cellId}>{Number.isFinite(cell) ? round(cell) : cell}</span> ); }; The styling for the grid is as follows: table { width: 100%; table-layout: fixed; font-size: inherit; line-height: inherit; border-collapse: collapse; align-self: flex-start; margin: 0.5rem; caption { text-align: left; height: 1.75rem; button { border: 0; margin-right: 0.25rem; } } th, td { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } th { padding: 0.25rem; cursor: pointer; border: solid #ddd; border-width: 1px 0; text-align: left; } td { border-bottom: 1px solid #eee; span { border-left: 2px solid transparent; padding: 0.25rem; line-height: 1.75rem; } } } Before we go any further, click around in the tabular view to get a sense of how this component works. ### The `ResultGraph` Component The graph view has a little bit more going on, but has the same principle as the grid: it takes the query `Id`, and the list of columns to use - this time distinguished as dimensions (for the x-axis) and measures (against the y-axis). The graph is rendered in SVG, and to lay it out effectively, we need to know the size of the component on the screen. To track this, we use the browser's `ResizeObserver` API, and set width and height state variables whenever it changes: const ResultGraph = ({queryId, dimensions, measures}) => { const ref = useRef(null); const [{width = 0, height = 0}, setDimensions] = useState({}); useEffect(() => { const observer = new ResizeObserver(([{contentRect}]) => setDimensions(contentRect), ); observer.observe(ref.current); return () => observer.disconnect(); }, [ref]); // ... Next we scan the data from the query to put it into the structure we need for the graph. We want a list of all the compound x-axis labels, and a series of y values for every measure. We also use this phase to get the maximum y value present so we can decide how high to make the y-axis: // ... const [xAllLabels, yValueSets, yMax] = useGraphData( queryId, dimensions, measures, ); // ... We'll look at the `useGraphData` implementation shortly. Next we take those values and prepare the configuration of the graph itself. The `useGraphSetup` hook (which we will also look at shortly) returns two functions we'll use to lay the SVG elements out. It also returns the exact labels to display on both axes, for example, which take into account how many can be shown, and at a suitable frequency. // ... const [xToPixel, yToPixel, xLabels, yLabels, xRange] = useGraphSetup( width, height, xAllLabels, yMax, ); // ... At this point, we can bail out if the page hasn't fully rendered and the width and height aren't set yet: // ... if (width == 0 || height == 0) { return <main ref={ref} />; } // ... But if they are, we can go ahead and build the SVG chart. Firstly the bounding box and x-axis: // ... return ( <main ref={ref}> <svg xmlns="http://www.w3.org/2000/svg" viewBox={`0 0 ${width} ${height}`} > <path d={`M${xToPixel(0)} ${yToPixel(0)}H${xToPixel(xRange)}`} /> Next, the x-axis labels, offset a little and rotated 90°: <> {xLabels.map((xLabel, x) => { const textX = xToPixel(x) - GRAPH_FONT / 2; const textY = yToPixel(0) + GRAPH_PADDING; return ( xLabel && ( <text transform={`translate(${textX} ${textY}) rotate(90)`} key={x}> {xLabel} </text> ) ); })} </> Then, the y-axis labels, right-aligned: <> {yLabels.map((yLabel) => { const textX = xToPixel(0) - GRAPH_PADDING; const textY = yToPixel(yLabel) + GRAPH_FONT / 2; return ( <text transform={`translate(${textX} ${textY})`} text-anchor="end" key={yLabel} > {yLabel} </text> ); })} </> And finally a `<path />` for each measure, as well as series of (by default hidden) `<circle />` and `<text />` elements tht serve as hover-over labels for each data point: {/* ... */} {yValueSets.map((yValueSet, s) => ( <g className={measures[s]} key={s}> <path d={yValueSet .map( (y, x) => `${x == 0 ? 'M' : 'L'}${xToPixel(x)} ${yToPixel(y)}`, ) .join('')} /> {yValueSet.map((y, x) => ( <> <circle cx={xToPixel(x)} cy={yToPixel(y)} r={GRAPH_PADDING} /> <text x={xToPixel(x)} y={yToPixel(y) - GRAPH_FONT}> {xAllLabels[x]} {measures[s]}: {round(y)} </text> </> ))} </g> ))} </svg> </main> ); }; There's some styling for the SVG chart, which also takes care of the behavior of the hover-over tooltips. svg { stroke: #666; stroke-width: 1; fill: #333; text { stroke: #fff; stroke-width: 4; paint-order: stroke; font-size: 12; } path { fill: none; } circle { stroke-width: 2; fill: #fff; opacity: 0; & + text { text-anchor: middle; display: none; } &:hover { opacity: 1; & + text { display: block; } } } } We have also had a cheeky convention of setting the CSS class names to be `Cell` `Ids`. This allows us to have a consistent color scheme for the measures, across both the sidebar, the table, and the graph: .measure(@color) { stroke-width: 2; stroke: @color; border-color: @color; fill: @color; } .MPG { .measure(#FFB300); } .Cylinders { .measure(#803e75); } .Displacement { .measure(#FF6800); } .Horsepower { .measure(#A6BDD7); } .Weight { .measure(#C10020); } .Acceleration { .measure(#98ce62); } ### Structuring the Graph Data The following two hooks are needed to configure the data and layout for the graph. The first takes the query and the two types of columns. For each `Row`, it concatenates the dimensional cells together (to create the labels on the x-axis) and puts the quantitative measure cells into columnar series so we can plot each line. It also determines the maximum y value to be displayed on the chart. Note that the `Row` `Ids` are sorted according to the values of the first measure selected. In a real analytical scenario, this would be better to be configurable. const useGraphData = (queryId, dimensions, measures) => { const table = useResultTable(queryId); const sortedRowIds = useResultSortedRowIds(queryId, measures[0] ?? undefined); return useMemo(() => { const yAll = [1]; const xAllLabels = []; const yValueSets = measures.map(() => []); sortedRowIds.forEach((rowId) => { const row = table[rowId]; xAllLabels.push( dimensions.map((dimensionColumn) => row[dimensionColumn]).join(', '), ); measures.forEach((measureColumn, m) => { yAll.push(row[measureColumn]); yValueSets[m].push(row[measureColumn]); }); }); return [xAllLabels, yValueSets, Math.max(...yAll)]; }, [table, sortedRowIds, measures, dimensions]); }; The second hook is responsible for configuring the graph's layout. It takes the width and height of the containing element, all the available x-axis labels, and the maximum y value. It then returns two functions for mapping from x/y values to SVG pixels, the set of actual labels to display on each axis (taking into account spacing them so they don't overlap), and the ranges of the two axes. It's not particularly important to understand the mathematics of this hook to understand TinyBase! These are mostly just to create well-spaced labels on the axes, and in a real-world application, you'd be more likely to use more powerful charting support from something like D3 or Vega. const useGraphSetup = (width, height, xAllLabels, yMax) => useMemo(() => { const xOffset = height / 4; const yOffset = width / 15; const xWidth = width - yOffset - GRAPH_PADDING; const yHeight = height - xOffset - GRAPH_PADDING; const xRange = xAllLabels.length - 1; const xLabels = xAllLabels.map((label, x) => x % Math.ceil((GRAPH_FONT * xRange) / xWidth) == 0 ? label : null, ); const yMaxMagnitude = Math.pow(10, Math.floor(Math.log10(yMax))); const yRange = Math.ceil(yMax / yMaxMagnitude) * yMaxMagnitude; const yMajorSteps = Math.ceil(yMax / yMaxMagnitude); const yMinorSteps = yMajorSteps <= 2 ? 5 : yMajorSteps <= 5 ? 2 : 1; const yLabels = Array(yMinorSteps * yMajorSteps + 1) .fill() .map((_, i) => (i * yMaxMagnitude) / yMinorSteps); return [ (x) => yOffset + (x * xWidth) / xRange, (y) => GRAPH_PADDING + yHeight - (y * yHeight) / yRange, xLabels, yLabels, xRange, yRange, ]; }, [width, height, xAllLabels, yMax]); ### The `[`Select`](/api/queries/type-aliases/definition/select/)` Component To build the `Body` application sidebar, we use some `<select />` elements. This simple `[`Select`](/api/queries/type-aliases/definition/select/)` component provides a wrapper around that to callback with either the array of values, or a single value, when selected. const Select = ({options, selected, onOptionsChange, multiple = true}) => { const handleOptionsChange = useCallback( ({target}) => onOptionsChange( multiple ? [...target.selectedOptions].map((option) => option.value) : target.value, ), [onOptionsChange], ); return ( <select multiple={multiple} size={options.length} onChange={handleOptionsChange} > {options.map((option) => ( <option value={option} selected={selected.includes(option)} className={option} > {option} </option> ))} </select> ); }; Note that each option is given a CSS class so that we can display the consistent colors for each measure. The `[`Select`](/api/queries/type-aliases/definition/select/)` component has some styling of its own: select { border: 1px solid #ccc; display: block; font: inherit; letter-spacing: inherit; width: 10rem; option { border-left: 2px solid transparent; } } ### Default Styling We finish off with a few final pieces of CSS that are applied across the application. @font-face { font-family: Inter; src: url(https://tinybase.org/fonts/inter.woff2) format('woff2'); } * { box-sizing: border-box; } body { color: #333; font-family: Inter, sans-serif; letter-spacing: -0.04rem; font-size: 0.8rem; line-height: 1.4rem; margin: 0; user-select: none; } --- ## Page: https://tinybase.org/demos/movie-database/ * TinyBase * Demos * Movie Database In this demo, we build an app that showcases the relational query capabilities of TinyBase v2.0, joining together information about movies, directors, and actors.  - we use The Movie Database as the source of the movie information in this app. Thank you for a great data set to demonstrate TinyBase! ### An Overview Of The Data Before looking at code, let's familiarize ourselves with the data used in this application. #### `Tables` The raw data is loaded into four normalized `Table` objects: `movies` (the top 250 movies on TMDB), `genres` (the styles of those movies), `people` (actors and directors), and `cast` (how the actors are associated with the movies). Naturally, there are relationships between these. For example, the `directorId` `Cell` of the `movies` `Table` references a person in the `people` table, and the `genreId` `Cell` references the `genre` `Table`. The `cast` `Table` is a many-to-many join that contains all the `movieId`/`castId` pairs for the top three billed actors of each movie. | `Table` | `Cell` `Ids` | | --- | --- | | `movies` | `id`, `name`, `genreId`, `directorId`, `year`, `rating`, `overview`, `image` | | `genres` | `id`, `name` | | `people` | `id`, `name`, `gender`, `born`, `died`, `popularity`, `biography`, `image` | | `cast` | `id`, `movieId`, `castId` | Because the data is normalized (and fetched as TSV over the wire), it loads quickly. But generally this data isn't suited to render directly into the application: the user doesn't want to see the movie's `directorId`. They want to see the director's `name`! #### `Queries` The app therefore uses a set of queries against these underlying `Table` objects. These act as de-normalized 'views' of the underlying normalized data and make it easy for the application to render 'virtual' rows comprised of `Cell` values from multiple joined `Table` objects in the `Store`. Some of these, like the main `movies` query, are set up for the lifetime of the application: | Query | From `Tables` | `Cell` `Ids` | | --- | --- | --- | | `movies` | `movies`, `genres`, `people` | `movieId`, `movieName`, `movieImage`, `year`, `rating`, `genreId`, `genreName`, `overview`, `directorId`, `directorName`, `directorImage`, `castId1`, `castName1`, `castImage1`, `castId2`, `castName2`, `castImage2`, `castId3`, `castName3`, `castImage3` | | `years` | `movies` | `year`, `movieCount` | | `genres` | `movies` | `genreId`, `genreName`, `movieCount` | | `directors` | `movies`, `people` | `directorId`, `directorName`, `directorImage`, `gender`, `popularity`, `movieCount` | | `cast` | `cast`, `people` | `castId`, `castName`, `castImage`, `gender`, `popularity`, `movieCount` | Others, like the `moviesInYear` query, are set up when a specific page is being viewed (in that case, the detail page for a particular year): | Query | From `Tables` | `Cell` `Ids` | | --- | --- | --- | | `moviesInYear` | `movies`, `genres` | `movieId`, `movieName`, `movieImage`, `year`, `rating`, `genreId`, `genreName` | | `moviesInGenre` | `movies`, `genres` | `movieId`, `movieName`, `movieImage`, `year`, `rating`, `genreId`, `genreName` | | `moviesWithDirector` | `movies`, `genres` | `movieId`, `movieName`, `movieImage`, `year`, `rating`, `genreId`, `genreName` | | `moviesWithCast` | `cast`, `movies`, `genres` | `movieId`, `movieName`, `movieImage`, `year`, `rating`, `genreId`, `genreName` | You might notice that many of these queries share the same `Cell` `Ids`. You'll discover that TinyBase lets you compose queries programmatically, so we'll be able to build these queries without much repetition: the common `queryMovieBasics` function is used to select the same `Cell` `Ids` into most of these query views. Refer back to this section when we start loading the data, querying the data, and rendering them into the user interface. Otherwise, that's enough preamble... let's get to some code! ### Boilerplate First, we create the import aliases for TinyBase and React modules we'll need: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "tinybase/ui-react-dom": "https://esm.sh/tinybase@6.0.1/ui-react-dom", "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We're using the `Inspector` component for the purposes of seeing how the data is structured. We need the following parts of the TinyBase API, the `ui-react` module, and React itself: import {useMemo, useState} from 'react'; import React from 'react'; import {createRoot} from 'react-dom/client'; import {createQueries, createStore} from 'tinybase'; import { CellView, Provider, ResultCellView, useCell, useCreateQueries, useCreateStore, useQueries, useResultCell, useResultRowIds, useSetValuesCallback, useValues, } from 'tinybase/ui-react'; import {ResultSortedTableInHtmlTable} from 'tinybase/ui-react-dom'; import {Inspector} from 'tinybase/ui-react-inspector'; ### Initializing The Application In the main part of the application, we want to initialize a default `Store` (called `store`) and a named `Store` called `viewStore`. The latter serves the sole purpose of remembering the 'route' in the application, which describes which part of the user interface the user is looking at. We also initialize a `Queries` object, and use the `createAndInitQueries` function (which we'll describe later) to set up the queries for the application. The two `Store` objects and the `Queries` object are memoized by the useCreateStore method and useCreateQueries method so they are only created the first time the app is rendered. const App = () => { const store = useCreateStore(createStore); const viewStore = useCreateStore(() => createStore().setValues({currentSection: 'movies'}), ); const queries = useCreateQueries(store, createAndInitQueries, []); // ... This application depends on loading data into the main `Store` the first time it is rendered. We do this by having an `isLoading` flag in the application's state, and setting it to `false` only once the asynchronous loading sequence in the (soon-to-be described) `loadStore` function has completed. Until then, a loading spinner is shown. // ... const [isLoading, setIsLoading] = useState(true); useMemo(async () => { await loadStore(store); setIsLoading(false); }, []); return ( <Provider store={store} storesById={{viewStore}} queries={queries}> <Header /> {isLoading ? <Loading /> : <Body />} <Inspector /> </Provider> ); } We also added the `Inspector` component at the end there so you can inspect what is going on with the data during this demo. Simply click the TinyBase logo in the corner. With simple boilerplate code to load the component, off we go: addEventListener('load', () => createRoot(document.body).render(<App />)); ### Basic Components Before we get to the really interesting parts, let's dispense with the basic building blocks for the application's user interface. #### Loading Spinner First the loading spinner, a plain element with some CSS. const Loading = () => <div id="loading" />; This is styled as a 270° arc with a spinning animation: #loading { animation: spin 1s infinite linear; height: 2rem; margin: 40vh auto; width: 2rem; &::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="2rem" viewBox="0 0 100 100"><path d="M50 10A40 40 0 1 1 10 50" stroke="black" fill="none" stroke-width="4" /></svg>'); } } @keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } } #### Application Sections Next the structure of the application. It has four sections: 'Movies', 'Years', 'Genres', and 'People'. We define their outer components and create a keyed collection of them. Each either shows an overview (for example, all movies) or the detail of one particular record (for example, a single movie): const MoviesSection = ({detailId}) => detailId == null ? <MoviesOverview /> : <MovieDetail movieId={detailId} />; const YearsSection = ({detailId}) => detailId == null ? <YearsOverview /> : <YearDetail year={detailId} />; const GenresSection = ({detailId}) => detailId == null ? <GenresOverview /> : <GenreDetail genreId={detailId} />; const PeopleSection = ({detailId}) => detailId == null ? <PeopleOverview /> : <PersonDetail personId={detailId} />; const SECTIONS = { movies: ['Movies', MoviesSection], years: ['Years', YearsSection], genres: ['Genres', GenresSection], people: ['People', PeopleSection], }; The `viewStore` contains the section name and optionally a string detail ID (such as the movie's `Id`), both as `Values`. The `useRoute` hook gets this pair, and the `useSetRouteCallback` hook will be used to set it in response to the user clicking links in the app. const useRoute = () => useValues('viewStore'); const useSetRouteCallback = (currentSection, currentDetailId) => useSetValuesCallback( () => ({currentSection, currentDetailId}), [currentSection, currentDetailId], 'viewStore', ); Note that we're not doing anything fancy here like updating the browser's URL address or history state as the user navigates around, but we would add that in this method if we did. (Another easy, but interesting, exercise for the reader would be to use the `Checkpoints` API on the `viewStore` to create an in-app history stack for the pages viewed!) The header component simply renders all the links for the `SECTIONS` object and highlights the one that matches the current route. const Header = () => { const {currentSection} = useRoute(); return ( <nav> {Object.entries(SECTIONS).map(([section, [title]]) => ( <a className={currentSection == section ? 'current' : ''} onClick={useSetRouteCallback(section, null)} > {title} </a> ))} </nav> ); }; The header also has very simple styling: nav { background: #eee; display: flex; a { flex: 1; padding: 0.25rem; text-align: center; &.current { background: #ddd; font-weight: 600; } } } #### Application Body Finally, we use the current route to load the appropriate section and render it into the main part of the application: const Body = () => { const {currentSection, currentDetailId} = useRoute(); if (SECTIONS[currentSection] != null) { const [, Section] = SECTIONS[currentSection]; return ( <main> <Section detailId={currentDetailId} /> </main> ); } return null; }; Again, this component has minimal styling: main { padding: 0.5rem; } ### Loading The Data We now move onto the more interesting data manipulation and rendering logic in the application. The movie data for the application is sourced from TMDB and has been converted into a tab-separated variable format. TSV files are smaller and faster than JSON to load over the wire, but nonetheless, we load them asynchronously. The logic here should be reasonably self-evident. We extract the column names from the top of the TSV, check each row has a matching cardinality, use the first column as the `Row` `Id`, coerce numeric `Cell` values, and load everything into a standard `Table`. const NUMERIC = /^[\d\.]+$/; const loadTable = async (store, tableId) => { const rows = ( await (await fetch(`https://tinybase.org/assets/${tableId}.tsv`)).text() ).split('\n'); const cellIds = rows.shift().split('\t'); rows.forEach((row) => { const cells = row.split('\t'); if (cells.length == cellIds.length) { const rowId = cells.shift(); cells.forEach((cell, c) => { if (cell != '') { if (NUMERIC.test(cell)) { cell = parseFloat(cell); } store.setCell(tableId, rowId, cellIds[c + 1], cell); } }); } }); }; There are four raw tables used in this application, as described in our opening section. We wait for all four to load (again, asynchronously) wrapped in a single `Store` transaction. const loadStore = async (store) => { store.startTransaction(); await Promise.all( ['movies', 'genres', 'people', 'cast'].map((tableId) => loadTable(store, tableId), ), ); store.finishTransaction(); }; `loadStore` was the function referenced in the main `App` component, so once this completes, the data is loaded and we're ready to go. ### Querying The Data As described at the start of this article, there are a number of queries that are set up at the beginning of the app and which are used throughout its lifetime. These include the list of all movies with de-normalized genre, director, and cast names (a query called `movies`); and queries that group counts of movies by genre, year, and so on. Since many queries throughout the app re-use the same set of fields - such as movie name, image, year, and genre - we can first create a re-usable function that specifies those `Cell` `Ids`. This can be used whenever the `movies` `Table` is the root of the query. When provided a `select` and `join` function, it selects those common columns: const queryMovieBasics = ({select, join}) => { select((_, movieId) => movieId).as('movieId'); select('name').as('movieName'); select('image').as('movieImage'); select('year'); select('rating'); select('genreId'); select('genres', 'name').as('genreName'); join('genres', 'genreId'); }; Notice that we use a convention in this app of suffixing `[`Id`](/api/common/type-aliases/identity/id/)`, `Name` and `Image` to resulting `Cell` `Ids`. In the `App` component we described at the start of the article, we call a function called `createAndInitQueries` to initialize the queries. Here's its implementation: const createAndInitQueries = (store) => { const queries = createQueries(store); // ... First we define the `movies` query that populates the main overview of the movies section of the app, and provides the detail about the movie's overview, directors and cast. The join to the `people` `Table` for the director name and image should be self-explanatory. What is slightly more interesting is the way we join to get the cast names and images. Each movie has up to three cast members captured in the `cast` many-to-many join `Table` - and so we join through _that_ table three times according to their billing. // ... queries.setQueryDefinition('movies', 'movies', ({select, join}) => { queryMovieBasics({select, join}); select('overview'); select('directorId'); select('directors', 'name').as('directorName'); select('directors', 'image').as('directorImage'); join('people', 'directorId').as('directors'); [1, 2, 3].forEach((billing) => { const castJoin = `cast${billing}`; join('cast', (_, movieId) => `${movieId}/${billing}`).as(castJoin); select(castJoin, 'castId').as(`castId${billing}`); const actorJoin = `actors${billing}`; join('people', castJoin, 'castId').as(actorJoin); select(actorJoin, 'name').as(`castName${billing}`); select(actorJoin, 'image').as(`castImage${billing}`); }); }); // ... For the SQL-inclined amongst you, we've created something more or less equivalent to the following query: -- movies query SELECT movies._rowId AS movieId, movies.name AS movieName, movies.image AS movieImage, movies.year AS year, movies.rating AS rating, movies.genreId AS genreId, genres.name AS genreName, movies.overview AS overview, movies.directorId AS directorId, directors.name AS directorName, directors.image AS directorImage, cast1.castId AS castId1, actors1.name AS castName1, actors1.image AS castImage1, cast2.castId AS castId2, actors2.name AS castName2, actors2.image AS castImage2, cast3.castId AS castId3, actors3.name AS castName3, actors3.image AS castImage3 FROM movies LEFT JOIN genres ON genres._rowId = movies.genreId LEFT JOIN people AS directors ON directors._rowId = movies.directorId LEFT JOIN cast AS cast1 ON cast1._rowId = CONCAT(movies.movieId, '/1') LEFT JOIN people AS actors1 ON actors1._rowId = cast1.castId LEFT JOIN cast AS cast2 ON cast2._rowId = CONCAT(movies.movieId, '/2') LEFT JOIN people AS actors2 ON actors2._rowId = cast2.castId LEFT JOIN cast AS cast3 ON cast3._rowId = CONCAT(movies.movieId, '/3') LEFT JOIN people AS actors3 ON actors3._rowId = cast3.castId (Except the results in our case are reactive - not something you usually get to benefit from with a SQL-based database!) We also create query definitions for the other persistent queries. These use the `group` function to count the number of movies per year, genre, and so on, used in the overview components of each of the main sections of the app. // ... queries.setQueryDefinition('years', 'movies', ({select, group}) => { select('year'); select((_, rowId) => rowId).as('movieId'); group('movieId', 'count').as('movieCount'); }); queries.setQueryDefinition('genres', 'movies', ({select, join, group}) => { select('genreId'); select((_, rowId) => rowId).as('movieId'); join('genres', 'genreId'); select('genres', 'name').as('genreName'); group('movieId', 'count').as('movieCount'); }); queries.setQueryDefinition('directors', 'movies', ({select, join, group}) => { select('directorId'); select((_, rowId) => rowId).as('movieId'); select('people', 'name').as('directorName'); select('people', 'image').as('directorImage'); select('people', 'gender'); select('people', 'popularity'); join('people', 'directorId'); group('movieId', 'count').as('movieCount'); }); queries.setQueryDefinition('cast', 'cast', ({select, join, group}) => { select('castId'); select('movieId'); select('people', 'name').as('castName'); select('people', 'image').as('castImage'); select('people', 'gender'); select('people', 'popularity'); join('people', 'castId'); group('movieId', 'count').as('movieCount'); }); return queries; } That's it for the main persistent queries that power most of the major views of the app. We'll refer to these by their query `Id` when we actually bind them to components. ### The `ResultSortedTableInHtmlTable` Component Most of the movies app is built from tabular data views, and it's nice to have a re-usable `<table>` rendering, complete with sorting, pagination, and formatting. Previously there was a whole table implementation in this demo, but as of TinyBase v4.1, we just use the `ResultSortedTableInHtmlTable` component from the new `ui-react-dom` module straight out of the box! You'll see we use it throughout the app. This component also benefits from some light styling for the pagination buttons and the table itself: table { border-collapse: collapse; font-size: inherit; line-height: inherit; margin-top: 0.5rem; table-layout: fixed; width: 100%; caption { text-align: left; button { border: 0; margin-right: 0.25rem; } } th, td { padding: 0.25rem 0.5rem 0.25rem 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } th { border: solid #ddd; border-width: 1px 0; cursor: pointer; text-align: left; width: 20%; &:nth-child(1) { width: 40%; } } td { border-bottom: 1px solid #eee; } } ### Linking Between Views Throughout the app, if you click on a movie name (wherever you see it), we'll want to take you to the movie detail. Clicking a year should take you to the list of movies for the year, a genre to the list of movies in a genre - and so on. For example, the `MovieLink` component creates a link that sets the route to be the `movies` section, with the value of the `movieId` `Cell` from the result `Row` being rendered. The `ImageFromQuery` component we'll discuss later. Here we create a collection of tiny custom components which, when passed a `queryId` and `rowId` can render a decorated link which, when clicked, update the application's route. We will be using these in the customCell props of the `ResultSortedTableInHtmlTable` instances to configure them as custom cell renderers. This component obviously assumes that the `movieId`, `movieImage`, and `movieName` `Cell` `Ids` are present in the query, so we only use this for queries that meaningfully populate those. const MovieLink = ({queryId, rowId}) => { const movieId = useResultCell(queryId, rowId, 'movieId'); return ( <a onClick={useSetRouteCallback('movies', movieId)}> <ImageFromQuery queryId={queryId} rowId={rowId} cellId="movieImage" /> <ResultCellView queryId={queryId} rowId={rowId} cellId="movieName" /> </a> ); }; We do the same for queries that contain a `year` `Cell` `Id`, for those that contain `genreId` and `genreName` `Cell` `Ids`, and for those that contain `directorId` and `directorName` `Cell` `Ids`: const YearLink = ({queryId, rowId}) => { const year = useResultCell(queryId, rowId, 'year'); return ( <a onClick={useSetRouteCallback('years', year)}> <ResultCellView queryId={queryId} rowId={rowId} cellId="year" /> </a> ); }; const GenreLink = ({queryId, rowId}) => { const genreId = useResultCell(queryId, rowId, 'genreId'); return ( <a onClick={useSetRouteCallback('genres', genreId)}> <ResultCellView queryId={queryId} rowId={rowId} cellId="genreName" /> </a> ); }; const DirectorLink = ({queryId, rowId}) => { const personId = useResultCell(queryId, rowId, 'directorId'); return ( <a onClick={useSetRouteCallback('people', personId)}> <ImageFromQuery queryId={queryId} rowId={rowId} cellId="directorImage" /> <ResultCellView queryId={queryId} rowId={rowId} cellId="directorName" /> </a> ); }; We do the same for actors, but for the billed cast members we need to check tor existence, in case a movie does not have three actors: const CastLink = ({queryId, rowId, billing = ''}) => { const personId = useResultCell(queryId, rowId, `castId${billing}`); return personId == null ? null : ( <a onClick={useSetRouteCallback('people', personId)}> <ImageFromQuery queryId={queryId} rowId={rowId} cellId={`castImage${billing}`} /> <ResultCellView queryId={queryId} rowId={rowId} cellId={`castName${billing}`} /> </a> ); }; ### Helper Components Throughout the app, we'll use a few simple helper components. Let's get them defined up front. These take the numeric TMDB gender `Id` (from the `Row` of a `Table` or the result `Row` of query) and render it as a string: const GenderFromQuery = ({queryId, rowId}) => genderString(useResultCell(queryId, rowId, 'gender')); const GenderFromTable = ({tableId, rowId}) => genderString(useCell(tableId, rowId, 'gender')); const genderString = (genderId) => { switch (genderId) { case 1: return 'Female'; case 2: return 'Male'; case 3: return 'Non-binary'; default: return 'Unknown'; } }; These take a TMDB image path (again from the `Row` of a `Table` or the result `Row` of query) and render it in large or small format: const ImageFromQuery = ({queryId, rowId, cellId, isLarge}) => ( <Image imageFile={useResultCell(queryId, rowId, cellId)} isLarge={isLarge} /> ); const ImageFromTable = ({tableId, rowId, cellId, isLarge}) => ( <Image imageFile={useCell(tableId, rowId, cellId)} isLarge={isLarge} /> ); const Image = ({imageFile, isLarge}) => ( <img src={`https://image.tmdb.org/t/p/w${isLarge ? 92 : 45}${imageFile}`} className={isLarge ? 'large' : ''} /> ); These images have some light styling: img { width: 1rem; height: 1.5rem; object-fit: cover; vertical-align: top; margin: 0 0.25rem 0 0; &.large { width: 4rem; height: 6rem; margin: 0 0.5rem 1rem 0; object-fit: contain; float: left; + ul + * { clear: both; } } } And finally, this trivial component just wraps the main view of the application to apply a consistent title to the top of each page: const Page = ({title, children}) => ( <> <h1>{title}</h1> {children} </> ); With these helper components out of the way, let's move on to the main parts of the app. ### Overview Components We now use the `ResultSortedTableInHtmlTable` component and these linker components to create the major views of the application. First, the overview of all the rated movies in the database (which displays on the 'Movies' tab when the app first loads), comprising a `ResultSortedTableInHtmlTable` that renders the `movies` query with four columns, sorted by rating. const MoviesOverview = () => ( <Page title="Rated movies"> <ResultSortedTableInHtmlTable queryId="movies" customCells={customCellsForMoviesOverview} cellId="rating" descending={true} limit={20} sortOnClick={true} paginator={true} idColumn={false} /> </Page> ); const customCellsForMoviesOverview = { movieName: {label: 'Movie', component: MovieLink}, year: {label: 'Year', component: YearLink}, rating: {label: 'Rating'}, genreName: {label: 'Genre', component: GenreLink}, }; Note how three of the columns are given a custom component to render the value not as raw text but as a link. We'll describe those simple components later. The 'Years' tab renders the `years` query with two columns: the year and the number of movies in that year: const YearsOverview = () => ( <Page title="Years"> <ResultSortedTableInHtmlTable queryId="years" customCells={customCellsForYearsOverview} cellId="year" limit={20} descending={true} sortOnClick={true} paginator={true} idColumn={false} /> </Page> ); const customCellsForYearsOverview = { year: {label: 'Year', component: YearLink}, movieCount: {label: 'Rated movies'}, }; Similarly, the 'Genres' tab renders the `genres` query with two columns: the genre and the number of movies in that genre: const GenresOverview = () => ( <Page title="Genres"> <ResultSortedTableInHtmlTable queryId="genres" customCells={customCellsForGenresOverview} cellId="movieCount" descending={true} limit={20} sortOnClick={true} paginator={true} idColumn={false} /> </Page> ); const customCellsForGenresOverview = { genreName: {label: 'Genre', component: GenreLink}, movieCount: {label: 'Rated movies'}, }; Finally, the 'People' tab renders two tables: the `directors` query and the `cast` query with four columns each, both sorted by popularity: const PeopleOverview = () => ( <Page title="People"> <h2>Directors</h2> <ResultSortedTableInHtmlTable queryId="directors" customCells={customCellsForDirectorsOverview} cellId="popularity" descending={true} limit={20} sortOnClick={true} paginator={true} idColumn={false} /> <h2>Cast</h2> <ResultSortedTableInHtmlTable queryId="cast" customCells={customCellsForCastOverview} cellId="popularity" descending={true} limit={20} sortOnClick={true} paginator={true} idColumn={false} /> </Page> ); const customCellsForDirectorsOverview = { directorName: {label: 'Director', component: DirectorLink}, gender: {label: 'Gender', component: GenderFromQuery}, popularity: {label: 'Popularity'}, movieCount: {label: 'Rated movies'}, }; const customCellsForCastOverview = { castName: {label: 'Cast', component: CastLink}, gender: {label: 'Gender', component: GenderFromQuery}, popularity: {label: 'Popularity'}, movieCount: {label: 'Rated movies'}, }; Click through each of the section headings in the app so you can see how each of these is working. ### Detail Components We also have a detail view for each section, which drills into a specific movie, year, genre, or person. Firstly, for a single movie (assuming it exists!), we isolate a row in the de-normalized `movies` query and render its `Cell` values in a page format: const MovieDetail = ({movieId}) => { const props = {queryId: 'movies', rowId: movieId}; const name = useResultCell('movies', movieId, 'movieName'); return name == null ? null : ( <Page title={name}> <ImageFromQuery {...props} cellId="movieImage" isLarge={true} /> <ul> <li> Year: <YearLink {...props} /> </li> <li> Genre: <GenreLink {...props} /> </li> <li> Rating: <ResultCellView {...props} cellId="rating" /> </li> </ul> <p> <ResultCellView {...props} cellId="overview" /> </p> <h2>Credits</h2> <ul> <li> <DirectorLink {...props} />, director </li> <li> <CastLink {...props} billing={1} /> </li> <li> <CastLink {...props} billing={2} /> </li> <li> <CastLink {...props} billing={3} /> </li> </ul> </Page> ); }; Again, note how we are often using custom components that take the `Cell` values from this result `Row` to make links to other parts of the app. We'll describe those shortly. Moving on, the detail for a specific year is just a sorted table of the movies from that year. But here is a case where we need to run the query within the component (rather than globally across the app). The `moviesInYear` query is constructed whenever the `year` prop changes, and uses the `where` function to show the basic movie data for just those movies matching that year. We get to benefit from the `queryMovieBasics` function again since we just need movie `Id`, name, rating and genre. const YearDetail = ({year}) => { const queries = useQueries(); useMemo( () => queries.setQueryDefinition( 'moviesInYear', 'movies', ({select, join, where}) => { queryMovieBasics({select, join}); where('year', year); }, ), [year], ); return ( <Page title={`Movies from ${year}`}> <ResultSortedTableInHtmlTable queryId="moviesInYear" customCells={customCellsForMoviesInYear} cellId="rating" descending={true} limit={20} sortOnClick={true} paginator={true} idColumn={false} /> </Page> ); }; const customCellsForMoviesInYear = { movieName: {label: 'Movie', component: MovieLink}, rating: {label: 'Rating'}, genreName: {label: 'Genre', component: GenreLink}, }; The genre detail page is very similar, with a `where` clause to match the genre's `Id`: const GenreDetail = ({genreId}) => { const queries = useQueries(); useMemo( () => queries.setQueryDefinition( 'moviesInGenre', 'movies', ({select, join, where}) => { queryMovieBasics({select, join}); where('genreId', genreId); }, ), [genreId], ); const name = useCell('genres', genreId, 'name'); return name == null ? null : ( <Page title={`${name} movies`}> <ResultSortedTableInHtmlTable queryId="moviesInGenre" customCells={customCellsForMoviesInGenre} cellId="rating" descending={true} limit={20} sortOnClick={true} paginator={true} idColumn={false} /> </Page> ); }; const customCellsForMoviesInGenre = { movieName: {label: 'Movie', component: MovieLink}, year: {label: 'Year', component: YearLink}, rating: {label: 'Rating'}, }; Finally, we build the detail page for a person. We create two queries on the fly here, one for those movies for which the person is the director, and one for those in which they are cast. The latter is slightly more complex since it needs to use the many-to-many `cast` `Table` as its root, from where it joins to the `movies` `Table` and `genres` `Table` in turn. Nevertheless, the result `Cell` `Ids` are named to be consistent with the other queries, so that we can use the same custom components to render each part of the HTML table. This component is also slightly more complex that the others because it is also rendering some parts of its content directly from the `people` `Table` (rather than via a query) - hence the use of the basic `useCell` hook and `CellView` component, for example. const PersonDetail = ({personId}) => { const queries = useQueries(); useMemo( () => queries .setQueryDefinition( 'moviesWithDirector', 'movies', ({select, join, where}) => { queryMovieBasics({select, join}); where('directorId', personId); }, ) .setQueryDefinition( 'moviesWithCast', 'cast', ({select, join, where}) => { select('movieId'); select('movies', 'name').as('movieName'); select('movies', 'image').as('movieImage'); select('movies', 'year'); select('movies', 'rating'); select('movies', 'genreId'); select('genres', 'name').as('genreName'); join('movies', 'movieId'); join('genres', 'movies', 'genreId'); where('castId', personId); }, ), [personId], ); const props = {tableId: 'people', rowId: personId}; const name = useCell('people', personId, 'name'); const died = useCell('people', personId, 'died'); const moviesWithDirector = useResultRowIds('moviesWithDirector'); const moviesWithCast = useResultRowIds('moviesWithCast'); return name == null ? null : ( <Page title={name}> <ImageFromTable {...props} cellId="image" isLarge={true} /> <ul> <li> Gender: <GenderFromTable {...props} /> </li> <li> Born: <CellView {...props} cellId="born" /> {died && ( <> ; died: <CellView {...props} cellId="died" /> </> )} </li> <li> Popularity: <CellView {...props} cellId="popularity" /> </li> </ul> <p> <CellView {...props} cellId="biography" /> </p> {moviesWithDirector.length == 0 ? null : ( <> <h2>As director:</h2> <ResultSortedTableInHtmlTable queryId="moviesWithDirector" customCells={customCellsForMoviesWithPeople} cellId="rating" descending={true} limit={20} sortOnClick={true} paginator={true} idColumn={false} /> </> )} {moviesWithCast.length == 0 ? null : ( <> <h2>As cast:</h2> <ResultSortedTableInHtmlTable queryId="moviesWithCast" customCells={customCellsForMoviesWithPeople} cellId="rating" descending={true} limit={20} sortOnClick={true} paginator={true} idColumn={false} /> </> )} </Page> ); }; const customCellsForMoviesWithPeople = { movieName: {label: 'Movie', component: MovieLink}, year: {label: 'Year', component: YearLink}, rating: {label: 'Rating'}, genreName: {label: 'Genre', component: GenreLink}, }; ### Default Styling Just for completeness, here is the default CSS styling and typography that the app uses: @font-face { font-family: Inter; src: url(https://tinybase.org/fonts/inter.woff2) format('woff2'); } * { box-sizing: border-box; } body { user-select: none; font-family: Inter, sans-serif; letter-spacing: -0.04rem; font-size: 0.8rem; line-height: 1.5rem; margin: 0; color: #333; } h1 { margin: 0 0 1rem; } h2 { margin: 1.5rem 0 0.5rem; } ul { padding-left: 0; } li { display: block; padding-bottom: 0.25rem; } a { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; cursor: pointer; max-width: 100%; display: inline-block; vertical-align: top; color: #000; &:hover { text-decoration: underline; } } p { line-height: 1.2rem; } ### Conclusion And that's it! There is quite a lot going on here, but this is arguably a complete and legitimate application that pulls in a decent amount of normalized relational data and renders it in a useful and navigable fashion. One thing that might be easy to forget (because the movie data does not change) is that every single view rendered in this application is reactive! Were any aspect of the data dynamic, it would be automatically updated in the user interface. It's left as an exercise to the reader to explore how this might work, perhaps with movie favorite lists, or pulling dynamic data directly from TMDB. In the meantime, as they say in the movies, that's a wrap! --- ## Page: https://tinybase.org/demos/word-frequencies/ In this demo, we load the list of the 10,000 most common words in English, index them for a fast search experience, and showcase TinyBase v2.1's ability to register a `Row` in multiple `Slice` arrays of an `Index`. We use the New General Service List by Browne, C., Culligan, B., and Phillips, J. as the source of these words and frequencies, and the derivative words.tsv is shared under CC BY-SA 4.0. Thank you! ### Boilerplate First, we create the import aliases for TinyBase and React modules we'll need: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We need the following parts of the TinyBase API, the `ui-react` module, and React itself: import {useCallback, useMemo, useState} from 'react'; import React from 'react'; import {createRoot} from 'react-dom/client'; import {createIndexes, createStore} from 'tinybase'; import { Provider, useCreateIndexes, useCreateStore, useRow, useSliceRowIds, } from 'tinybase/ui-react'; ### Loading The Data The word data for the application has been converted into a tab-separated variable format with a ranked row per word, and its typical frequency per million words. | | | | --- | --- | | the | 60910 | | be | 48575 | | and | 30789 | | ... | ... | | eigenvalue | 0 | TSV files are smaller and faster than JSON to load over the wire, but nonetheless, we load it asynchronously and insert it into the `words` `Table` in a single transaction: const loadWords = async (store) => { const words = ( await (await fetch(`https://tinybase.org/assets/words.tsv`)).text() ).split('\n'); store.transaction(() => words.forEach((row, rowId) => { const [word, perMillion] = row.split('\t'); store.addRow('words', { rank: rowId + 1, word, perMillion: Number(perMillion), }); }), ); }; As you can see, each `Row` in the `words` `Table` ends up with three `Cell` `Ids`: `rank`, `word`, and `perMillion`. ### Indexing The Data In the main part of the application, we will initialize an `Indexes` object called `indexes`. This has an `Index` defined, called `stems`, which has a `Slice` for every stem of every word. For example, the word `the` will appear in the Slices with `Ids` `''`, `t`, `th`, and `the`. The word `theme` will appear in those too, as well as those with `Ids` `them` and `theme` - and so on. We build the `Index` with the `setIndexDefinition` method, providing a custom function that returns the stems (including the empty string) for each word: const indexWords = (store) => createIndexes(store).setIndexDefinition('stems', 'words', (getCell) => { const word = getCell('word'); const stems = []; for (let l = 0; l <= word.length; l++) { stems.push(word.substring(0, l)); } return stems; }); The `Index` of 10,000 words comprises almost 30,000 of these stems, containing over 80,000 word entries between them. Nevertheless, building this index takes less than 250ms, even on my feeble old laptop. (Note that this indexing strategy is reasonably naive. For a large-scale autocomplete application, a data structure like a Trie or Patricia tree might be more appropriate.) ### Initializing The Application In the main part of the application, we want to initialize a default `Store` called `store`, and an `Indexes` object called `indexes`. The latter is initialized with the function above. The two objects are memoized by the useCreateStore method and useCreateIndexes method so they are only created the first time the app is rendered. const App = () => { const store = useCreateStore(createStore); const indexes = useCreateIndexes(store, indexWords); // ... To provide a spinner while the words are loading and being indexed, we have an `isLoading` flag in the application's state, and setting it to `false` only once the asynchronous loading sequence (described above) has completed. Until then, a loading spinner is shown. For the loaded application, the UI comprises literally just the input box and the results. They are bound together using just one state variable (`stem`), which contains the text that the user has entered into the search box. // ... const [isLoading, setIsLoading] = useState(true); useMemo(async () => { await loadWords(store); setIsLoading(false); }, []); const [stem, setStem] = useState(''); return ( <Provider store={store} indexes={indexes}> {isLoading ? ( <Loading /> ) : ( <> <Input stem={stem} onChange={setStem} /> <Results stem={stem} /> </> )} </Provider> ); } Let's go! addEventListener('load', () => createRoot(document.body).render(<App />)); ### The `Input` Component The search box is a very lightly wrapped `<input>` element that displays the stem and reacts to changes, firing the `onChange` prop. const Input = ({stem, onChange}) => ( <input value={stem} onChange={useCallback(({target: {value}}) => onChange(value), [])} placeholder="Search for a word" autoFocus={true} spellCheck={false} /> ); We have a little bit of styling for this too: input { border: 0; border-bottom: 1px solid #999; display: block; font: inherit; letter-spacing: inherit; font-weight: 600; margin: 1rem auto; outline: 0; padding: 0; width: 20rem; } ### The `Results` Component Since we did all the hard work up front to index the corpus of words, fetching the results is trivial! We take the stem, get the array of `Row` `Ids` that are in that pre-indexed `Slice`, and then render a `Result` component for the first 14 of them: const Results = ({stem}) => { const resultRowIds = useSliceRowIds('stems', stem.toLowerCase()); return ( resultRowIds.length > 0 && resultRowIds .slice(0, 14) .map((rowId) => <Result rowId={rowId} stemLength={stem.length} />) ); }; Why 14? That's the number that seems to fit neatly in the window above. But there is very little performance impact to having a much larger result list if you wish. We pass down the `stemLength` prop simply so each `Result` row can embolden the matching characters. #### The `Result` Component For each matching word (identified by its `Row` `Id` in the `words` `Table` of the default `Store`), we want to display the word, its rank, and its frequency: const Result = ({rowId, stemLength}) => { const {rank, word, perMillion} = useRow('words', rowId); return ( <div className="result"> <b>{word.substring(0, stemLength)}</b> {word.substring(stemLength)} <small> <b> {rank} {suffix(rank)} </b> , {frequency(perMillion)} </small> </div> ); }; We style this: .result { display: block; width: 20rem; margin: 0.25rem auto; small { float: right; color: #777; font-size: 0.7rem; } } The `suffix` function simply puts the ordinal suffixes '-th', '-st', '-nd', and '-rd' at the end of the ranking number: const suffix = (rank) => { switch (rank % 100) { case 11: case 12: case 13: return 'th'; } switch (rank % 10) { case 1: return 'st'; case 2: return 'nd'; case 3: return 'rd'; default: return 'th'; } }; And the frequency function takes the number of times the word typically appears per million words and displays a percentage: const frequency = (perMillion) => { if (perMillion < 10) { return 'rare'; } return (perMillion / 10000).toFixed(3) + '%'; }; ### `Loading` Component And App Styling Just for completeness, here is the loading spinner, a plain element with some CSS. const Loading = () => <div id="loading" />; This is styled as a 270° arc with a spinning animation: #loading { animation: spin 1s infinite linear; height: 2rem; margin: 40vh auto; width: 2rem; &::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="2rem" viewBox="0 0 100 100"><path d="M50 10A40 40 0 1 1 10 50" stroke="black" fill="none" stroke-width="4" /></svg>'); } } @keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } } And finally, for completeness, here is the remaining styling for the application as whole: @font-face { font-family: Inter; src: url(https://tinybase.org/fonts/inter.woff2) format('woff2'); } * { box-sizing: border-box; } body { color: #333; font-family: Inter, sans-serif; letter-spacing: -0.04rem; font-size: 1rem; line-height: 1.2rem; margin: 0; user-select: none; } ### Conclusion And that's it! This demo hopefully explained how the new multi-`Slice` indexing feature in TinyBase v2.1 can be used to create interesting (and high performance) user experiences with your data. --- ## Page: https://tinybase.org/demos/ui-components/ In this set of demos, we use a `Store` containing some sample data to showcase the UI components in the `ui-react-dom` module. ## <ValuesInHtmlTable /> In this first demo, we set up a `Store` containing some sample data, and showcase the `ValuesInHtmlTable` component. Read more. ## <TableInHtmlTable /> In this demo, we showcase the `TableInHtmlTable` component. Read more. ## <SortedTableInHtmlTable /> In this demo, we showcase the `SortedTableInHtmlTable` component, a more complex and interactive way to render a TinyBase `Table`. Read more. ## <SliceInHtmlTable /> In this demo, we showcase the `SliceInHtmlTable` component, a way to display the `Slice` portions of an `Index`. Read more. ## <RelationshipInHtmlTable /> In this demo, we showcase the `RelationshipInHtmlTable` component, a way to display the two `Tables` linked together by a `Relationship`. Read more. ## <ResultTableInHtmlTable /> In this demo, we showcase the `ResultTableInHtmlTable` component, a way to display the results of a query. Read more. ## <ResultSortedTableInHtmlTable /> In this demo, we showcase the `ResultSortedTableInHtmlTable` component, a more complex and interactive way to render the results of a query. Read more. ## <EditableValueView /> In this demo, we showcase the `EditableValueView` component, which allows you to edit `Values` in the `Store` in a web environment. Read more. ## <EditableCellView /> In this demo, we showcase the `EditableCellView` component, which allows you to edit `Cell` values in the `Store` in a web environment. Read more. ## <Inspector /> In this demo, we showcase the `Inspector` component, which allows you to view and edit the content of a `Store` in a debug web environment. Read more. --- ## Page: https://tinybase.org/demos/hello-world/hello-world-v1/ In this demo, we set data in, and then get data from, a `Store` object. We're using keyed values (not even tabular data!), so this is about as simple as it gets. First, since we're running this in a browser, we register some import aliases for `esm.sh`: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1" } } </script> We import the `createStore` function, create the `Store` object with it: import {createStore} from 'tinybase'; const store = createStore(); **NB**: If we had bundled TinyBase with this app, we could have used a regular import instead of having to destructure the `TinyBaseStore` global. We set the string 'Hello World' as a `Value` in the `Store` object. We give it a `Value` `Id` of `v1`: store.setValue('v1', 'Hello World'); Finally, we get the value back out again and update the page with it: document.body.innerHTML = store.getValue('v1'); Add a little styling, and we're done! @font-face { font-family: Inter; src: url(https://tinybase.org/fonts/inter.woff2) format('woff2'); } body { align-items: center; display: flex; font-family: Inter, sans-serif; letter-spacing: -0.04rem; height: 100vh; justify-content: center; margin: 0; } And we're done! You now know the basics of setting and getting TinyBase data. Next, we will see how we could have done that using a tabular data structure. Please continue to the Hello World v2 demo. --- ## Page: https://tinybase.org/demos/hello-world/hello-world-v2/ * TinyBase * Demos * Hello World * Hello World v2 In this demo, we again set data in, and then get data from, a `Store` object. But this time we're using tabular data. It's just a few changes to do so. Firstly we use the `setCell` method instead of the `setValue` method: -store.setValue('v1', 'Hello World'); +store.setCell('t1', 'r1', 'c1', 'Hello World'); As you can see, instead of setting the keyed `Value` called `v1`, we're putting the data in a `Cell` called `c1`, in a `Row` called `r1`, in a `Table` called `t1`: We also need to update the way in which we get the value back out again: -document.body.innerHTML = store.getValue('v1'); +document.body.innerHTML = store.getCell('t1', 'r1', 'c1'); The result is the same but now hopefully you get a sense for how the keyed value and tabular APIs to the `Store` are going to work. Next, we will set up a listener for data in the `Store` object and then change the `Cell` to see the display update. Please continue to the Hello World v3 demo. --- ## Page: https://tinybase.org/demos/hello-world/hello-world-v3/ In this demo, we set up a listener for data in the `Store` object and then change the `Cell` to see the display update. We're making changes to the Hello World v2 demo. Instead of populating the `Store` object with a static `Cell` value, we use the current time: -store.setCell('t1', 'r1', 'c1', 'Hello World'); +const setTime = () => { + store.setCell('t1', 'r1', 'c1', new Date().toLocaleTimeString()); +}; +setTime(); We also create a function that updates the DOM with the value of the `Cell`, and run it once to initialize the display: -document.body.innerHTML = store.getCell('t1', 'r1', 'c1'); +const update = () => { + document.body.innerHTML = store.getCell('t1', 'r1', 'c1'); +}; +update(); We then add that `update` function as a `CellListener` so that every change to the `Cell` causes it to be called: store.addCellListener('t1', 'r1', 'c1', update); To stimulate the `CellListener`, we update the time every second: setInterval(setTime, 1000); Next, we will use React to render data in the `Store` object and then change a `Cell` to see the display update. Please continue to the Hello World v4 demo. --- ## Page: https://tinybase.org/demos/hello-world/hello-world-v4/ In this demo, we use React to render data in the `Store` object and then change a `Cell` to see the display update. We're making changes to the Hello World v3 demo. In addition to the TinyBase module, we'll pull in React, ReactDOM, and the TinyBase React modules: <script type="importmap"> { "imports": { - "tinybase": "https://esm.sh/tinybase@6.0.1" + "tinybase": "https://esm.sh/tinybase@6.0.1", + "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", + "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", + "react": "https://esm.sh/react@^19.0.0", + "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", + "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> Since we're now using React, we can use the `Inspector` component for the purposes of seeing how the data is structured. We import the extra functions and components we need: import {createStore} from 'tinybase'; +import {CellView, Provider} from 'tinybase/ui-react'; +import {Inspector} from 'tinybase/ui-react-inspector'; +import {createRoot} from 'react-dom/client'; +import React from 'react'; Instead of writing to the document, we create a React app comprising a single `CellView` component. This also takes care of setting up a `CellListener` for us and rendering the result: -const update = () => { - document.body.innerHTML = store.getCell('t1', 'r1', 'c1'); -}; -update(); -store.addCellListener('t1', 'r1', 'c1', update); +createRoot(document.body).render( + <Provider store={store}> + <CellView tableId="t1" rowId="r1" cellId="c1" /> + <Inspector /> + </Provider>, +); We also added the `Inspector` component at the end there so you can inspect what is going on with the data during this demo. Simply click the TinyBase logo in the corner. Next, we will use a `Metrics` object to keep a count (and a rolling average) of the values in each `Cell` in a `Store`. Please continue to the Averaging Dice Rolls demo. --- ## Page: https://tinybase.org/demos/rolling-dice/averaging-dice-rolls/ In this demo, we use a `Metrics` object to keep a count (and a rolling average) of the values in each `Cell` in a `Store`. We roll a dice 48 times and keep track of the average. First, we create the import aliases for TinyBase and React modules we'll need: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We're using the `Inspector` component for the purposes of seeing how the data is structured. We import the functions and components we need, and create the `Store` object: import React from 'react'; import {createRoot} from 'react-dom/client'; import {createMetrics, createStore} from 'tinybase'; import {MetricView, Provider, TableView, useCell} from 'tinybase/ui-react'; import {Inspector} from 'tinybase/ui-react-inspector'; const store = createStore(); To create the `Metrics` object, we use the `createMetrics` function, and configure two definitions for it: const metrics = createMetrics(store) .setMetricDefinition('average', 'rolls', 'avg', 'result') .setMetricDefinition('count', 'rolls', 'sum'); Each roll is going to be rendered as a dice Unicode character: const Roll = ({tableId, rowId}) => ( <span className="roll"> {String.fromCharCode(9855 + useCell(tableId, rowId, 'result'))} </span> ); The dice require a little styling: .roll { display: inline-block; font-size: 3rem; padding: 0 1rem; line-height: 3rem; } We then create a React app comprising two `MetricView` components and a `TableView` component which will render the `Roll` components: createRoot(document.body).render( <Provider store={store} metrics={metrics}> <p> Count: <MetricView metricId="count" /> <br /> Average: <MetricView metricId="average" /> </p> <TableView tableId="rolls" rowComponent={Roll} /> <Inspector /> </Provider>, ); We also added the `Inspector` component at the end there so you can inspect what is going on with the data during this demo. Simply click the TinyBase logo in the corner. To roll the dice, we add a new `Row` every half second with the result, until the count of rolls reaches 48: let rolls = 0; const interval = setInterval(() => { if (rolls++ == 48) { clearInterval(interval); } else { store.addRow('rolls', { result: Math.ceil(Math.random() * 6), }); } }, 500); Add a little styling, and we're done! @font-face { font-family: Inter; src: url(https://tinybase.org/fonts/inter.woff2) format('woff2'); } body { font-family: Inter, sans-serif; letter-spacing: -0.04rem; margin: 0; } p { margin: 1rem; } Next, we will use an `IndexView` component to group each `Row` of the `Store` object based on the value in a `Cell` within it. Please continue to the Grouping Dice Rolls demo. --- ## Page: https://tinybase.org/demos/rolling-dice/grouping-dice-rolls/ In this demo, we use an `IndexView` component to group each `Row` of the `Store` object based on the value in a `Cell` within it. We roll a dice 48 times and index the rolls by result. We're making changes to the Averaging Dice Rolls demo. We import the extra functions and components we need: -import {createMetrics, createStore} from 'tinybase'; +import {createIndexes, createStore} from 'tinybase'; -import {MetricView, Provider, TableView, useCell} from 'tinybase/ui-react'; +import {IndexView, Provider, SliceView, useCell} from 'tinybase/ui-react'; To create the `Indexes` object, we use createIndexes, and configure an index called `rolls` for the `Table` called `rolls`, based on the `result` `Cell` of each roll `Row`. It sorts the dice rolls according to the color of the dice (ie `blue`, then `green`, then `red` for each given result). We don't need metrics in this demo: -const metrics = createMetrics(store) - .setMetricDefinition('average', 'rolls', 'avg', 'result') - .setMetricDefinition('count', 'rolls', 'sum'); +const indexes = createIndexes(store).setIndexDefinition( + 'rolls', + 'rolls', + 'result', + 'color', +); As in the previous demo, each roll is going to be rendered as a dice Unicode character, but we'll add color as a CSS class: const Roll = ({tableId, rowId}) => ( - <span className="roll">+ <span className={`roll ${useCell(tableId, rowId, 'color')}`}> {String.fromCharCode(9855 + useCell(tableId, rowId, 'result'))} </span> ); .roll { display: inline-block; font-size: 3rem; padding: 0 1rem; line-height: 3rem; + &.red { + color: #900; + } + &.green { + color: #090; + } + &.blue { + color: #009; + } } We create a component for each slice. Its main purpose is to put each `SliceView` component on a new line and ensure the `Roll` component is used for each roll `Row`: const Rolls = (props) => ( <div className="rolls"> <SliceView {...props} rowComponent={Roll} /> </div> ); .rolls { white-space: nowrap; } We then change our React app to comprise an `IndexView` component which will render the `Rolls` component for each slice in the index (in turn rendering the `Roll` component for each roll `Row`): -createRoot(document.body).render( - <Provider store={store} metrics={metrics}> - <p> - Count: <MetricView metricId="count" /> - <br /> - Average: <MetricView metricId="average" /> - </p> - <TableView tableId="rolls" rowComponent={Roll} /> - <Inspector /> - </Provider>, -); createRoot(document.body).render( <Provider store={store} indexes={indexes}> <IndexView indexId="rolls" sliceComponent={Rolls} /> <Inspector /> </Provider>, ); To roll the dice, we again add a new `Row` every half second with the result, but also add a random color: store.addRow('rolls', { result: Math.ceil(Math.random() * 6), + color: ['red', 'green', 'blue'][Math.floor(Math.random() * 3)], }); Next, we will build a minimum viable 'Todo' app. Please continue to the Todo App v1 (the basics) demo. --- ## Page: https://tinybase.org/demos/todo-app/todo-app-v1-the-basics/ In this demo, we build a minimum viable 'Todo' app. It uses React and a simple `Store` to let people add new todos and then mark them as done. ### Initialization First, we create the import aliases for TinyBase and React modules we'll need: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We're using the `Inspector` component for the purposes of seeing how the data is structured. We import the functions and components we need: import {useCallback, useState} from 'react'; import React from 'react'; import {createRoot} from 'react-dom/client'; import {createStore} from 'tinybase'; import { CellView, Provider, TableView, useAddRowCallback, useCell, useCreateStore, useSetCellCallback, } from 'tinybase/ui-react'; import {Inspector} from 'tinybase/ui-react-inspector'; In this demo, we'll start with some sample data. We'll have a single `Table`, called `todos`, that has three `Row` entries, each representing one todo: const INITIAL_TODOS = { todos: { 0: {text: 'Clean the floor'}, 1: {text: 'Install TinyBase'}, 2: {text: 'Book holiday'}, }, }; ### The Top-Level `App` Component We have a top-level `App` component, in which we create our `Store` object with the sample data, and render the parts of the app. We use the `useCreateStore` hook, since it provides memoization in case the component is rendered more than once. We _could_ drill the `Store` object as a React prop down to all the components, but for clarity we wrap our app with the `Provider` component, which then makes it available throughout our app as the default `Store` object: const App = () => { const store = useCreateStore(() => createStore().setTables(INITIAL_TODOS)); return ( <Provider store={store}> <Title /> <NewTodo /> <Todos /> <Inspector /> </Provider> ); }; We also added the `Inspector` component at the end there so you can inspect what is going on with the data during this demo. Simply click the TinyBase logo in the corner. The app only has three components: `Title`, `NewTodo` (a form to enter a new todo), and `Todos` (a list of all of the todos). We use LESS to create a grid layout and some defaults for the app's styling: @accentColor: #d81b60; @spacing: 0.5rem; @border: 1px solid #ccc; @font-face { font-family: Inter; src: url(https://tinybase.org/fonts/inter.woff2) format('woff2'); } body { display: grid; grid-template-columns: 35% minmax(0, 1fr); grid-template-rows: auto 1fr; box-sizing: border-box; font-family: Inter, sans-serif; letter-spacing: -0.04rem; grid-gap: @spacing * 2 @spacing; margin: 0; min-height: 100vh; padding: @spacing * 2; * { box-sizing: border-box; outline-color: @accentColor; } } When the window loads, we render the `App` component to start the app: window.addEventListener('load', () => createRoot(document.body).render(<App />), ); Let's look at each of three components that make up the app. ### The `Title` Component There's not much to say about this component for now. It's just the title for the app. We'll do more with this later! const Title = () => 'Todos'; ### The `NewTodo` Component This component lets the user add a new todo. It's a standard managed `input` element that keeps the `text` of the input box in the component's state, and also listens to key presses: const NewTodo = () => { const [text, setText] = useState(''); const handleChange = useCallback(({target: {value}}) => setText(value), []); const handleKeyDown = useAddRowCallback( 'todos', ({which, target: {value: text}}) => which == 13 && text != '' ? {text} : null, [], undefined, () => setText(''), [setText], ); return ( <input id="newTodo" onChange={handleChange} onKeyDown={handleKeyDown} placeholder="New Todo" value={text} /> ); }; The `useAddRowCallback` hook creates us a callback that is run whenever the user presses a key. This lets us check if the key pressed was the Return key, and if there's some text in the input box. If so, it adds a new todo `Row` to the default `Store` object and then resets the form. As an aside, it may seem like the `useAddRowCallback` hook has a lot of parameters, since we are providing two functions, each with their own lists of dependencies (which are used to memoize them). The first parameter is the `Id` of the table to which we are adding a row. The second is the handler that takes the event and produces the new row to be added and the third parameter is the list of dependencies for that function (of which there are none). The fourth parameter would allow us to explicitly specify which `Store` object to use, but `undefined` gives us the app-wide default. The fifth parameter is a 'then' callback which will run after the row has been added (which is where we can reset the input field) and the sixth and final parameter is a list of dependencies for that (of which there is only one, the function used to do that setting of the input field text). The input box also needs styling: #newTodo { border: @border; display: block; font: inherit; letter-spacing: inherit; padding: @spacing; width: 100%; } ### The `Todos` Component To get the list of todos, we build a very simple component, comprising the `TableView` component right out of the box. We pass props to that component to indicate that we will be rendering each `Row` of the `todos` `Table`. Since we are not providing a `store` prop to the TableView, it will also use the default one from the `Provider` component that we wrapped the whole app in: const Todos = () => ( <ul id="todos"> <TableView tableId="todos" rowComponent={Todo} /> </ul> ); The final `rowComponent` prop for the `TableView` component allows us to specify how each `Row` is rendered. We will have our own `Todo` component (described below) to do this. Finally, we style this part of the app and position it in the grid: #todos { grid-column: 2; margin: 0; padding: 0; } ### The `Todo` Component This simple `Todo` component renders a simple `div` containing the todo's `text` that toggles the `done` flag when clicked. Each `Row` has two fields: the todo's `text` and an optional boolean indicating whether it is `done` that we get using the `useCell` hook. We also create a simple `handleClick` callback that negates the `done` flag when the text is clicked: const Todo = (props) => ( <li className="todo"> <TodoText {...props} /> </li> ); const TodoText = ({tableId, rowId}) => { const done = useCell(tableId, rowId, 'done'); const className = 'text' + (done ? ' done' : ''); const handleClick = useSetCellCallback(tableId, rowId, 'done', () => !done, [ done, ]); return ( <span className={className} onClick={handleClick}> <CellView tableId={tableId} rowId={rowId} cellId="text" /> </span> ); }; **NB**: The parent `TableView` component passes its `Store` object as a prop to each of its `rowComponent` children (such as this `TodoText`). We could have used that prop here, but instead we're using the `useSetCellCallback` hook that will automatically get it from the `Provider` component. Finally, we style each of these todos, and use the `done` CSS class to strike through those that are completed: #todos .todo { background: #fff; border: @border; display: flex; margin-bottom: @spacing; padding: @spacing; .text { cursor: pointer; flex: 1; overflow: hidden; text-overflow: ellipsis; user-select: none; white-space: nowrap; &::before { content: '\1F7E0'; padding: 0 0.5rem 0 0.25rem; } &.done { color: #ccc; &::before { content: '\2705'; } } } } ### Summary That's it: a simple Todo app made from a handful of tiny components, and less than 100 lines of generously-formatted code. Next, we will build a more complex viable 'Todo' app. Please continue to the Todo App v2 (indexes) demo. --- ## Page: https://tinybase.org/demos/todo-app/todo-app-v2-indexes/ In this demo, we build a more complex 'Todo' app. In addition to what we built in Todo App v1 (the basics), we let people specify a type for each todo, such as 'Home', 'Work' or 'Archived'. We also index those types with an `Indexes` object so that people can see their todos filtered by each type. We're making changes to the Todo App v1 (the basics) demo. ### Additional Initialization We'll be creating an `Indexes` object in this demo, so we'll need an additional import, the `useCreateIndexes` hook. We'll also use a `SliceView` component to display the index, instead of the simple `TableView` component that we used before. We'll be using a `Value` for the view state, so we'll also import the `useSetValueCallback` hook and `useValue` hook. -import {createStore} from 'tinybase'; +import {createIndexes, createStore} from 'tinybase'; import { CellView, Provider, - TableView, + SliceView, useAddRowCallback, useCell, + useCreateIndexes, useCreateStore, useSetCellCallback, + useSetValueCallback, + useValue, } from 'tinybase/ui-react'; We're defining a list of the types a todo can have, and giving our default todos each a different initial type: +const TYPES = ['Home', 'Work', 'Archived']; const INITIAL_TODOS = { todos: { - 0: {text: 'Clean the floor'}, + 0: {text: 'Clean the floor', type: 'Home'}, - 1: {text: 'Install TinyBase'}, + 1: {text: 'Install TinyBase', type: 'Work'}, - 2: {text: 'Book holiday'}, + 2: {text: 'Book holiday', type: 'Archived'}, }, }; ### Adding Additional Stores And `Indexes` In this demo we let people select a todo type and see a filtered list. The current type being displayed will need to be known by components across the app. We could make this a part of the top level component's state and pass it around with props. But instead, we will create and memoize a second `Store` object called `viewStore` to store the current type being viewed, in a `Value` called `type`. We also want to index the todos by type, so we create and memoize an `Indexes` object, and define an index called `types` on the `todos` `Table`, based on the value of the `type` `Cell`: const App = () => { const store = useCreateStore(() => createStore().setTables(INITIAL_TODOS)); + const viewStore = useCreateStore(() => + createStore().setValue('type', 'Home'), + ); + const indexes = useCreateIndexes(store, (store) => + createIndexes(store).setIndexDefinition('types', 'todos', 'type'), + ); return ( - <Provider store={store}>+ <Provider store={store} storesById={{viewStore}} indexes={indexes}> <Title /> <NewTodo /> + <Types /> <Todos /> <Inspector /> </Provider> ); }; Notice that we pass the new `viewStore` and `indexes` down into the app using the same Provider that we used for the `store` in Todo App v1. We need to pass `viewStore` in the `storesById` prop so we can refer to it explicitly for the components that need it (to disambiguate it from the default `Store` object that we provided in the `store` prop). We also added a new component to the app called `Types`. This is a side-bar that lists the types so people can pick one and view the filtered `Todos` list for it. ### The `Types` Component This new component goes on the left-hand side of the demo and lists the available types. When people click a type name, the current type will be set in the `viewStore` and the list on the right will be filtered accordingly. (Additionally, a new todo will be set to have this current type when it's added.) The component literally just enumerates the `TYPES` array and creates a `Type` component for each one: const Types = () => ( <ul id="types"> {TYPES.map((type) => ( <Type key={type} type={type} /> ))} </ul> ); #types { margin: 0; } ### The `Type` Component In the `Types` component, each type appears as a clickable name. The `viewStore` provides the currently selected type, and if it matches, this component will have an additional CSS class added to it. If the component is clicked, the `viewStore`'s value will be updated with a callback provided by the `useSetValueCallback` hook: const Type = ({type}) => { const currentType = useValue('type', 'viewStore'); const handleClick = useSetValueCallback( 'type', () => type, [type], 'viewStore', ); const className = 'type' + (type == currentType ? ' current' : ''); return ( <li className={className} onClick={handleClick}> {type} </li> ); }; **NB**: In this example, we are setting up one listener on the `viewStore` for every instance of the `Type` component in the side bar. This makes the `Type` components completely self-sufficient. An alternative approach would be to use the `useCell` hook once in the parent `Types` component and pass down the current type as a prop to each item. We would then pass a parameter to the `useSetCellCallback` hook to set the value based on the item clicked. Which of these two approaches is optimal in the general case will depend on the number and complexity of the children components being rendered. For example we will do something similar to this in the Countries demo, which has a longer list of items in the side bar). The `Type` component has a small amount of styling: #types .type { cursor: pointer; margin-bottom: @spacing; user-select: none; &.current { color: @accentColor; } } ### Upgrading The `Todos` Component Previously we used a `TableView` component to list all the todos in the `todos` `Table` of the `store`. But now we want to show only the todos of the current type. We created an `Indexes` object (called `indexes`) that has an index called `types`. Within that is one slice per type, which we can render with a `SliceView` component. The slices in an index are simply sets of `Row` `Ids` from a `Table` grouped according to the index's definition. Often - as here - these are sets of `Row` `Ids` that share a particular `Cell` value. We simply need to change the `Todos` component to fetch the current type from the `viewStore`, and then pass the corresponding index slice to the `SliceView` component. It still uses the `Todo` component to render each `Row` itself: const Todos = () => ( <ul id="todos">- <TableView tableId="todos" rowComponent={Todo} /> + <SliceView + indexId="types" + sliceId={useValue('type', 'viewStore')} + rowComponent={Todo} + /> </ul> ); ### Upgrading The `Todo` Component Since the todo has the new `type` `Cell`, we can display that alongside the text. In fact, we want to let people change the type for each todo too, so we implement a new component called `TodoType` that contains as a `select` dropdown. It has a callback to update the todo `Row` with the value from the `select` element if it is changed: const Todo = (props) => ( <li className="todo"> <TodoText {...props} />+ <TodoType {...props} /> </li> ); const TodoType = ({tableId, rowId}) => { const type = useCell(tableId, rowId, 'type'); const handleChange = useSetCellCallback( tableId, rowId, 'type', ({target: {value}}) => value, [], ); return ( <select className="type" onChange={handleChange} value={type}> {TYPES.map((type) => ( <option>{type}</option> ))} </select> ); }; We can style the `select` element so that it appears on the right of the `Todo` component: #todos .todo .type { border: none; color: #777; font: inherit; font-size: 0.8rem; margin-top: 0.1rem; } ### Upgrading the `NewTodo` Component Our final step is to make sure that when someone adds a new todo it defaults to the current type from the `viewStore` - if only so that the newly-created todo appears in the current `IndexView` component: const NewTodo = () => { const [text, setText] = useState(''); + const type = useValue('type', 'viewStore'); const handleChange = useCallback(({target: {value}}) => setText(value), []); const handleKeyDown = useAddRowCallback( 'todos', ({which, target: {value: text}}) => - which == 13 && text != '' ? {text} : null, + which == 13 && text != '' ? {text, type} : null, - [], + [type], undefined, () => setText(''), [setText], ); Note how the current type is now listed as a dependency for the handler function so that that function is correctly memoized. ### Summary And again, that's it: a fairly small set of changes to make the app a little more useful. But there's more! Next, we will build a yet more complex 'Todo' app, complete with persistence, a schema, and metrics. Please continue to the Todo App v3 (persistence) demo. --- ## Page: https://tinybase.org/demos/todo-app/todo-app-v3-persistence/ * TinyBase * Demos * Todo App * Todo App v3 (persistence) In this demo, we build a yet more complex 'Todo' app, complete with persistence and a schema. In the Todo App v1 (the basics) demo and the Todo App v2 (indexes) demo, refreshing the page reset all the todos, which didn't make it very useful. In this version, we demonstrate the basics of how to persist data from a `Store`. When data is persisted, it also valuable to have a schema for it, so in this demo we also add a simple schema for the todos. We're making changes to the Todo App v2 (indexes) demo. ### Additional Initialization We will call the `createLocalPersister` function to create a `Persister` object that persists the the main `Store` object in the browser's local store. Similarly, the `createSessionPersister` function will create a `Persister` object that persists the `Store` object containing the user's current view: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", + "tinybase/persisters/persister-browser": "https://esm.sh/tinybase@6.0.1/persisters/persister-browser", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> import {createIndexes, createStore} from 'tinybase'; +import {createLocalPersister, createSessionPersister} from 'tinybase/persisters/persister-browser'; import { CellView, Provider, SliceView, useAddRowCallback, useCell, useCreateIndexes, + useCreatePersister, useCreateStore, useSetCellCallback, useSetValueCallback, useValue, } from 'tinybase/ui-react'; ### Adding a `TablesSchema` A `Store` has a `setTablesSchema` method which can be used to describe a schema of each `Cell` present in each `Table`. Here we will indicate that the `text` `Cell` and `type` `Cell` are strings, and we default the `done` field to `false`. The `type` can only be one of the values of the `TYPES` array: const TYPES = ['Home', 'Work', 'Archived']; +const SCHEMA = { + todos: { + text: {type: 'string'}, + done: {type: 'boolean', default: false}, + type: {type: 'string', default: 'Home', allow: TYPES}, + }, +}; ### Persisting the `Store` We create and memoize a `Persister` object for the main `Store` object. We'll start it auto-loading from the browser's local storage immediately, but we can also provide `INITIAL_TODOS` as a default if nothing has been previously saved. We also set the `Persister` object to auto-save, creating a continuous synchronization between the in-memory version of the `Store` object and the copy of it in the browser's local storage. Note that we don't set the initial data of the `Store` object when we first create it, since it might have been persisted into the browser's local storage from a previous session, and we want to pick it up with the first load. We do configure the schema on creation though: - const store = useCreateStore(() => createStore().setTables(INITIAL_TODOS)); + const store = useCreateStore(() => createStore().setTablesSchema(SCHEMA)); + useCreatePersister( + store, + (store) => createLocalPersister(store, 'todos/store'), + [], + async (persister) => { + await persister.startAutoLoad([INITIAL_TODOS]); + await persister.startAutoSave(); + }, + ); We do something similar for the `viewStore`, so that reloads preserve the type currently being viewed. Instead of local storage, we'll use the browser's session storage. This means that if the user has two browser windows open, the UI changes to one won't mysteriously affect the other: - const viewStore = useCreateStore(() => - createStore().setValue('type', 'Home'), - ); + const viewStore = useCreateStore(() => + createStore().setValuesSchema({type: {type: 'string', default: 'Home'}}), + ); + useCreatePersister( + viewStore, + (store) => createSessionPersister(store, 'todos/viewStore'), + [], + async (persister) => { + await persister.startAutoLoad(); + await persister.startAutoSave(); + }, + ); Make some changes to the todos and then reload your browser! We now have a fairly useful app for tracking todos and persisting the state. Please continue to the Todo App v4 (metrics) demo. --- ## Page: https://tinybase.org/demos/todo-app/todo-app-v4-metrics/ In this version of the Todo app, we add a `Metrics` object that tracks the number of todos of each type and how many are not yet done. This allows us to show people how well they are getting through them. We're making changes to the Todo App v3 (persistence) demo. ### Additional Initialization We create a `Metrics` object with the `useCreateMetrics` hook, and will need the `useMetric` hook so that we can get the metrics out of it: -import {createIndexes, createStore} from 'tinybase'; +import {createIndexes, createMetrics, createStore} from 'tinybase'; import {createLocalPersister, createSessionPersister} from 'tinybase/persisters/persister-browser'; import { CellView, Provider, SliceView, useAddRowCallback, useCell, useCreateIndexes, + useCreateMetrics, useCreatePersister, useCreateStore, + useMetric, useSetCellCallback, useSetValueCallback, useValue, } from 'tinybase/ui-react'; ### Adding `Metrics` We define a metric to count how many todos are pending (i.e. not done), and then set up additional metrics to track the number of pending todos of each type. We use the `useCreateMetrics` hook to memoize this whole initialization: const indexes = useCreateIndexes(store, (store) => createIndexes(store).setIndexDefinition('types', 'todos', 'type'), ); + const metrics = useCreateMetrics(store, (store) => { + const metrics = createMetrics(store); + metrics.setMetricDefinition('pending', 'todos', 'sum', (getCell) => + !getCell('done') ? 1 : 0, + ); + TYPES.forEach((type) => { + metrics.setMetricDefinition(type, 'todos', 'sum', (getCell) => + getCell('type') == type && !getCell('done') ? 1 : 0, + ); + }); + return metrics; + }); As before, we make this `Metrics` object available though the `Provider` component as the default for the app: return ( - <Provider store={store} storesById={{viewStore}} indexes={indexes}>+ <Provider + store={store} + storesById={{viewStore}} + indexes={indexes} + metrics={metrics} + > <Title /> <NewTodo /> <Types /> <Todos /> <Inspector /> </Provider> ); }; ### Upgrading The `Title` Component Now that we are tracking the total number of pending todos, we can have that number appear in the title. We simply use the `useMetric` hook to get the `pending` metric that was previously defined: -const Title = () => 'Todos'; +const Title = () => { + const pending = useMetric('pending'); + + return pending > 0 ? `Todo: ${pending}` : 'All done!'; +}; ### Upgrading The `Type` Component We also have a metric for each type that represents the number of pending todos of that type. We can display those alongside each type name in the side-bar. Apart from the addition of the `pending` variable, this component is as it was in the previous version: const Type = ({type}) => { + const pending = useMetric(type); const currentType = useValue('type', 'viewStore'); const handleClick = useSetValueCallback( 'type', () => type, [type], 'viewStore', ); const className = 'type' + (type == currentType ? ' current' : ''); return ( <li className={className} onClick={handleClick}> {type}+ {pending > 0 ? ` (${pending})` : ''} </li> ); }; ### Summary We now have a fairly useful app for tracking todos. As a final flourish, we will add an undo and redo feature. Please continue to the Todo App v5 (checkpoints) demo. --- ## Page: https://tinybase.org/demos/todo-app/todo-app-v5-checkpoints/ * TinyBase * Demos * Todo App * Todo App v5 (checkpoints) In this version of the Todo app, we add a `Checkpoints` object that provides us with an undo and redo stack as the main store changes. We're making changes to the Todo App v4 (metrics) demo. ### Additional Initialization We will create a `Checkpoints` object with the `useCreateCheckpoints` hook, and will need the `useCheckpoints` hook to use it throughout the application. The `useSetCheckpointCallback` hook provides a callback to set a new checkpoint whenever something changes in the application that we would like to undo. The useUndo hook and useRedo hook provide convenient ways to check whether an undo action or a redo action is available: -import {createIndexes, createMetrics, createStore} from 'tinybase'; +import {createCheckpoints, createIndexes, createMetrics, createStore} from 'tinybase'; import {createLocalPersister, createSessionPersister} from 'tinybase/persisters/persister-browser'; import { CellView, + CheckpointView, Provider, SliceView, useAddRowCallback, useCell, + useCheckpoints, + useCreateCheckpoints, useCreateIndexes, useCreateMetrics, useCreatePersister, useCreateStore, useMetric, + useRedoInformation, + useRow, useSetCellCallback, + useSetCheckpointCallback, useSetValueCallback, + useUndoInformation, useValue, } from 'tinybase/ui-react'; As before, we make this `Checkpoints` object available though the `Provider` component as the default for the app. We choose to also clear the `Checkpoints` object once the persister has completed its initial load so that there isn't the option to 'undo' that first load: + const checkpoints = useCreateCheckpoints(store, createCheckpoints); useCreatePersister( store, (store) => createLocalPersister(store, 'todos/store'), [], async (persister) => { await persister.startAutoLoad([INITIAL_TODOS]); + checkpoints?.clear(); await persister.startAutoSave(); }, + [checkpoints], ); And as before, we make this `Checkpoints` object available though the `Provider` component as the default for the app: return ( <Provider store={store} storesById={{viewStore}} indexes={indexes} metrics={metrics} + checkpoints={checkpoints} > <Title /> <NewTodo /> <Types /> + <UndoRedo /> <Todos /> <Inspector /> </Provider> ); }; ### Upgrading `useNewTodoCallback` We need to set checkpoints every time something happens in the app that the user might want to undo. One of the actions we want to checkpoint is when a user creates a new todo: const NewTodo = () => { const [text, setText] = useState(''); const type = useValue('type', 'viewStore'); const handleChange = useCallback(({target: {value}}) => setText(value), []); + const addCheckpoint = useSetCheckpointCallback( + () => `adding '${text}'`, + [text], + ); const handleKeyDown = useAddRowCallback( 'todos', ({which, target: {value: text}}) => which == 13 && text != '' ? {text, type} : null, [type], undefined, - () => setText(''), + () => { + setText(''); + addCheckpoint(); + }, - [setText], + [setText, addCheckpoint], ); ### Upgrading `TodoType` When a type is changed for a todo, we also create a checkpoint: const TodoType = ({tableId, rowId}) => { const type = useCell(tableId, rowId, 'type'); + const checkpoints = useCheckpoints(); const handleChange = useSetCellCallback( tableId, rowId, 'type', ({target: {value}}) => value, [], + undefined, + (_store, type) => checkpoints.addCheckpoint(`changing to '${type}'`), + [checkpoints], ); The sixth parameter is left `undefined` so that the default store is used. Following that, we are using the 'then' callback that fires after the `Cell` has been set. It depends on `checkpoints`, so the final array parameter ensures it is memoized correctly. ### Upgrading `TodoText` And finally, we create a checkpoint whenever a todo is marked as completed or is being resumed. Our `handleClick` function now calls both the previous `setCell` function and a new `addCheckpoint` function, both from hooks that gave us the default `Store` and Checkpoint objects: const TodoText = ({tableId, rowId}) => { - const done = useCell(tableId, rowId, 'done'); + const {done, text} = useRow(tableId, rowId); const className = 'text' + (done ? ' done' : ''); - const handleClick = useSetCellCallback(tableId, rowId, 'done', () => !done, [ - done, - ]); + const setCell = useSetCellCallback(tableId, rowId, 'done', () => !done, [ + done, + ]); + const addCheckpoint = useSetCheckpointCallback( + () => `${done ? 'resuming' : 'completing'} '${text}'`, + [done], + ); + const handleClick = useCallback(() => { + setCell(); + addCheckpoint(); + }, [setCell, addCheckpoint]); return ( <span className={className} onClick={handleClick}> <CellView tableId={tableId} rowId={rowId} cellId="text" /> </span> ); }; ### The `UndoRedo` Components To provide a way to undo the checkpoints, we create two small affordances on the left of the application. Each is only enabled when there is at least one item to undo or redo: const UndoRedo = () => { const [canUndo, handleUndo, , undoLabel] = useUndoInformation(); const undo = canUndo ? ( <div id="undo" onClick={handleUndo}> undo {undoLabel} </div> ) : ( <div id="undo" className="disabled" /> ); const [canRedo, handleRedo, , redoLabel] = useRedoInformation(); const redo = canRedo ? ( <div id="redo" onClick={handleRedo}> redo {redoLabel} </div> ) : ( <div id="redo" className="disabled" /> ); return ( <div id="undoRedo"> {undo} {redo} </div> ); }; We extend the grid slightly so these components are placed below the type lists, and add some styling: body { display: grid; grid-template-columns: 35% minmax(0, 1fr); - grid-template-rows: auto 1fr; + grid-template-rows: auto auto 1fr; #undoRedo { grid-column: 1; grid-row: 3; #undo, #redo { cursor: pointer; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; user-select: none; &::before { padding-right: 0.5rem; vertical-align: middle; } &.disabled { cursor: default; opacity: 0.3; } } #undo::before { content: '\21A9'; } #redo::before { content: '\21AA'; } } Since we added an extra row to the grid we also make the right hand list of todos span two: #todos { grid-column: 2; + grid-row: 2 / span 2; margin: 0; padding: 0; } ### Summary It's been very straightforward to add a comprehensive undo and redo stack to our app. We could now declare it complete! But wouldn't is _also_ be cool to make it collaborative? If you agree, let's move on to the Todo App v6 (collaboration) demo... --- ## Page: https://tinybase.org/demos/todo-app/todo-app-v6-collaboration/ In this version of the Todo app, we use a `Synchronizer` to make the application collaborative. We're making changes to the Todo App v5 (checkpoints) demo. ### Server Implementation To have a collaborative experience, you need to deploy a server that can forward the WebSocket messages between multiple clients. TinyBase provides a ready-made server for this purpose called `WsServer`, available in the `synchronizer-ws-server` module. The server implementation for this demo is in the top level 'support' directory of the TinyBase repo for reference. At its heart, it is as simple as this: import {WsServer} from 'tinybase/synchronizers/synchronizer-ws-server'; const server = createWsServer(new ws.WebSocketServer({port: 8043})); The `createWsServer` function takes an instance of a WebSocketServer (from the well known ws library). This allows you to configure the underlying server in whatever ways you like. Note that in this demo, the server is not saving a copy of the data itself - it is merely acting as a broker between clients. Nevertheless, such a configuration would be possible if you were building an application that needed a server 'source-of-truth'. For the purposes of this demo, this server has been deployed to `todo.demo.tinybase.org`, and exposed on the HTTPS port of 443. On the client, we need to configure its address: const WS_SERVER = 'wss://todo.demo.tinybase.org/'; With our server deployed, let's go back to the rest of the client app... ### Only A `MergeableStore` Can Be Synchronized Up until now, we have been using a regular TinyBase `Store`. But in order to synchronize data between clients, we need to upgrade that to be a `MergeableStore` so that it tracks the metadata required to merge without conflicts. There is a simple change to the hook that creates the `Store`: -const store = useCreateStore(() => createStore().setTablesSchema(SCHEMA)); +const store = useCreateMergeableStore(() => + createMergeableStore().setTablesSchema(SCHEMA), +); Since a `MergeableStore` is fully compatible with the `Store` API, there are no other changes required within the app to accommodate this upgrade. ### Additional Imports To communicate with the server, we use a `WsSynchronizer`. This is in the `synchronizer-ws-client` module, and so we need to add that to our app: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/persisters/persister-browser": "https://esm.sh/tinybase@6.0.1/persisters/persister-browser", + "tinybase/synchronizers/synchronizer-ws-client": "https://esm.sh/tinybase@6.0.1/synchronizers/synchronizer-ws-client", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We import the function to create it accordingly: import {createCheckpoints, createIndexes, createMetrics, createStore} from 'tinybase'; +import {createWsSynchronizer} from 'tinybase/synchronizers/synchronizer-ws-client'; In turn, the `WsSynchronizer` is initialized with a WebSocket. This can be the browser's default implementation. We need to add in the new `Store` creation function as described above (as well as a unique `Id` generator we'll be using): -import {createCheckpoints, createIndexes, createMetrics, createStore} from 'tinybase'; +import { + createCheckpoints, + createIndexes, + createMergeableStore, + createMetrics, + createStore, + getUniqueId, +} from 'tinybase'; And we do the same for the creation hook, as well as making sure we add the `useCreateSynchronizer` hook: import { CellView, CheckpointView, Provider, SliceView, useAddRowCallback, useCell, useCheckpoints, useCreateCheckpoints, useCreateIndexes, + useCreateMergeableStore, useCreateMetrics, useCreatePersister, useCreateStore, + useCreateSynchronizer, useMetric, useRedoInformation, useRow, useSetCellCallback, useSetCheckpointCallback, useSetValueCallback, useUndoInformation, useValue, } from 'tinybase/ui-react'; ### Getting And Creating A Collaborative Space To make our application shareable, we need a unique space on the server where multiple clients can see the same todo list. The server supports multiple of these spaces, and distinguishes between them simply by the path used when the WebSocket connects. For example, one set of people might collaborate on a todo list brokered by `wss://todo.demo.tinybase.org/1234`, others on a todo list brokered by `wss://todo.demo.tinybase.org/5678` - and so on. We want to be able to create a unique `Id` from this app that can be used for the connection, and which updates the demo's browser URL so that it can be shared with others. There are many more sophisticated ways to do this, but we are going for a simple approach of using the URL query string to store our unique `Id`. We use a hook to store the path in the App's state, and which gets the initial value. It also provides a function that creates a new room and updates the URL accordingly. Helpfully, TinyBase provides a URL-safe unique `Id` generator, the `getUniqueId` function, that we can use: const useServerPathId = () => { const [serverPathId, setServerPathId] = useState( parent.location.search.substring(1), ); return [ serverPathId, useCallback(() => { const newServerPathId = getUniqueId(); parent.history.replaceState(null, null, '?' + newServerPathId); setServerPathId(newServerPathId); }, []), ]; }; Note that we work with the `parent` location, rather than the `window` object. This is simply because the TinyBase demo runs in a trusted iframe and needs to get the URL from the outer page. Fortunately `parent` still resolves to `window` even when this _isn't_ running in an iframe. ### Synchronizing to the server We use this new hook in the top level of the App component, and then create the `WsSynchronizer`. We make this conditional: if there is no server path `Id` (yet), the useCreateSynchronizer method returns nothing. Once a server path `Id` exists, it will instead create the `Synchronizer` asynchronously, using the address formed by combining the host and the path itself. Note that we are still persisting the data locally to local storage, but we put the `MergeableStore` in a different key to the `Store` from the previous demos (in case you go back to previous chapters and want the simpler `Store`'s serialization to be still present). useCreatePersister( store, - (store) => createLocalPersister(store, 'todos/store'), + (store) => createLocalPersister(store, 'todos/mergeableStore'), [], async (persister) => { await persister.startAutoLoad([INITIAL_TODOS]); checkpoints?.clear(); await persister.startAutoSave(); }, [checkpoints], ); + const [serverPathId, createServerPathId] = useServerPathId(); + useCreateSynchronizer( + store, + async (store) => { + if (serverPathId) { + const synchronizer = await createWsSynchronizer( + store, + new WebSocket(WS_SERVER + serverPathId), + ); + await synchronizer.startSync(); + checkpoints?.clear(); + return synchronizer; + } + }, + [serverPathId, checkpoints], + ); As we did for local storage, we also reset the checkpoints so this process does not appear on the undo stack. All that remains is to give the user a way to create the server path to start sharing! Let's add a single component called Share to do that. It takes the server path `Id` value and function from the app-level state, and renders either a button to create a room and start sharing, or a link to the room that is already being shared to. const Share = ({serverPathId, createServerPathId}) => ( <div id="share"> {serverPathId ? ( <a href={'?' + serverPathId} target="_blank"> 🔗 Share link </a> ) : ( <span onClick={createServerPathId}>📤 Start sharing</span> )} </div> ); We can add this to the top of the left-hand side of the app. For the sake of clarity, we remove the undo buttons for now: - <Title /> + <Share + serverPathId={serverPathId} + createServerPathId={createServerPathId} + /> <NewTodo /> <Types /> - <UndoRedo /> <Todos /> + <Title /> <Inspector /> Let's give it this share button some styling to make it prominent for this demo: #share { a, span { background: #eee; border: @border; color: #000; cursor: pointer; display: inline-block; padding: 0.5rem 1rem; text-align: center; text-decoration: none; width: 10rem; } a { border-color: @accentColor; background: #ddd; } } And we are good to go! Clicking the 'Start sharing' button will add a query string to the URL and start sharing to the WebSocket server. Clicking the 'Share link' button will launch a new browser window with the same server path `Id` in it. As you can see, the results are synchronized, but that's also because the tabs of your browser are sharing the local storage we set up in a previous demo. A better demo is to launch a new window in incognito mode or even a completely different browser! If all goes well, you will still see the shared todo list. ### Summary We went from local-first to collaboration with just a few additions of code and the magic of TinyBase synchronization. --- ## Page: https://tinybase.org/demos/ui-components/valuesinhtmltable/ In this first demo, we set up a `Store` containing some sample data, and showcase the `ValuesInHtmlTable` component. Due to the rendering of tables, these demos are probably best viewed on a desktop browser. ### Boilerplate First, we create the import aliases for TinyBase and React modules we'll need: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", "tinybase/ui-react-dom": "https://esm.sh/tinybase@6.0.1/ui-react-dom", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We need the following parts of the TinyBase API, the `ui-react` module, and React itself: import {useMemo, useState} from 'react'; import React from 'react'; import {createRoot} from 'react-dom/client'; import {createStore} from 'tinybase'; import {Provider, useCreateStore} from 'tinybase/ui-react'; import {ValuesInHtmlTable} from 'tinybase/ui-react-dom'; This is the main container of the demo, in a React component called `App`. It instantiates the `Store` with sample data (and memoizes it), and then renders the app with the `Store` in a Provider context so it's available throughout the app: const App = () => { const store = useCreateStore(createStore); const [isLoading, setIsLoading] = useState(true); useMemo(() => { loadValues(store); setIsLoading(false); }, []); return ( <Provider store={store}>{isLoading ? <Loading /> : <Body />}</Provider> ); }; addEventListener('load', () => createRoot(document.body).render(<App />)); ### Loading Data To start things off simple, we're loading a set of static `Values` into the `Store`, perhaps representing the user preferences of an app: const loadValues = (store) => { store .startTransaction() .setValue('username', 'John Appleseed') .setValue('email address', 'john.appleseed@example.com') .setValue('dark mode', true) .setValue('font size', 14) .finishTransaction(); }; Though currently synchronous, later demos will load data from a remote server, so let's set up a spinner now, to show while data loads: const Loading = () => <div id="loading" />; #loading { animation: spin 1s infinite linear; height: 2rem; margin: 40vh auto; width: 2rem; &::before { content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="2rem" viewBox="0 0 100 100"><path d="M50 10A40 40 0 1 1 10 50" stroke="black" fill="none" stroke-width="4" /></svg>'); } } @keyframes spin { from { transform: rotate(0); } to { transform: rotate(360deg); } } ### Default Styling These demos have some default CSS for typography and color. Let's get that out of the way early, too: @font-face { font-family: Inter; src: url(https://tinybase.org/fonts/inter.woff2) format('woff2'); } * { box-sizing: border-box; } body { align-items: flex-start; color: #333; display: flex; font-family: Inter, sans-serif; font-size: 0.8rem; min-height: 100vh; justify-content: space-around; letter-spacing: -0.04rem; line-height: 1.5rem; margin: 0; user-select: none; } ### Using the `ValuesInHtmlTable` Component OK, to the matter at hand. Now that we have a default `Store` in the Provider context, and it's populated with some data, we can render it with components from the `ui-react-dom` module. We will start off with a simple table, using the `ValuesInHtmlTable` component. This literally needs zero props to render the `Values`. const Body = () => { return ( <> <ValuesInHtmlTable /> That's it really. That's the demo. This emits simple DOM HTML for a table containing all the `Store`'s `Values`. Of course we should provide some light styling to emphasize the borders and headings and so on. Oh, and an obligatory drop shadow. table { background: white; border-collapse: collapse; box-shadow: 0 0 1rem #0004; font-size: inherit; line-height: inherit; margin: 2rem; table-layout: fixed; th, td { overflow: hidden; padding: 0.25rem 0.5rem; white-space: nowrap; border-width: 1px 0; border-style: solid; border-color: #eee; text-align: left; } thead th { border-bottom-color: #ccc; } button, input { border: 1px solid #ccc; } } And ta-da! You should see a styled table with the movie genre information in it. Note that you can disable the top header row and `Id` column on the left with the `headerRow` and `idColumn` props respectively. We can add a second table to demonstrate that: <ValuesInHtmlTable headerRow={false} idColumn={false} /> </> ); }; Take a look at the `ValuesInHtmlTableProps` type to see all the ways in which you can configure this component, and click the 'CodePen' link under the demo above to try them out. There is plenty more that you can do with the `ui-react-dom` module's components, and so please continue to the next <TableInHtmlTable /> demo. --- ## Page: https://tinybase.org/demos/ui-components/tableinhtmltable/ In this demo, we showcase the `TableInHtmlTable` component. Rather than building the whole demo and boilerplate from scratch, we're making changes to the <ValuesInHtmlTable /> demo to support this new component. ### Set Up We switch out the `ValuesInHtmlTable` component and import the `TableInHtmlTable` component instead. -import {ValuesInHtmlTable} from 'tinybase/ui-react-dom'; +import {TableInHtmlTable} from 'tinybase/ui-react-dom'; This component renders `Table` content rather than `Values`, so we change the load sequence to asynchronously load some tabular data, stealing from the Movie Database demo. - useMemo(() => { - loadValues(store); + useMemo(async () => { + await loadTable(store, 'genres'); setIsLoading(false); }, []); ### Loading Data We're loading a table of data in exactly the same way as we did in the Movie Database demo: const NUMERIC = /^[\d\.]+$/; const loadTable = async (store, tableId) => { store.startTransaction(); const rows = ( await (await fetch(`https://tinybase.org/assets/${tableId}.tsv`)).text() ).split('\n'); const cellIds = rows.shift().split('\t'); rows.forEach((row) => { const cells = row.split('\t'); if (cells.length == cellIds.length) { const rowId = cells.shift(); cells.forEach((cell, c) => { if (cell != '') { if (NUMERIC.test(cell)) { cell = parseFloat(cell); } store.setCell(tableId, rowId, cellIds[c + 1], cell); } }); } }); store.finishTransaction(); }; This is a small and narrow `Table`, with just `Id` and name for the sixteen movie genres. ### Using the `TableInHtmlTable` Component The `TableInHtmlTable` component is almost as simple as the `ValuesInHtmlTable` component, though it will need one prop, the `Table` `Id`: const Body = () => { return ( <>- <ValuesInHtmlTable /> - - <ValuesInHtmlTable headerRow={false} idColumn={false} /> + <TableInHtmlTable tableId='genres' /> + <TableInHtmlTable tableId='genres' headerRow={false} idColumn={false} /> </> ); }; Again, that's it. As before, you can see that you can disable the top header row and `Id` column on the left with the `headerRow` and `idColumn` props respectively. ### Customizing Cells This is a good opportunity to demonstrate how the table components can take custom components for rendering the inside of the cells in the table. Let's create a third table, where we pass in a custom component called `DictionaryCell`. That component should accept props for the `Table`, `Row`, and `Cell` `Ids` (as well as the `Store`) in order to create a different rendering. Here we create a link to an online dictionary for each word in the column: const DictionaryCell = ({tableId, rowId, cellId, store}) => { const word = useCell(tableId, rowId, cellId, store); return ( <a href={'https://www.merriam-webster.com/dictionary/' + word} target="_blank" > {word} </a> ); }; Also we need to update the imports to get access to the `useCell` hook: -import {Provider, useCreateStore} from 'tinybase/ui-react'; +import {Provider, useCell, useCreateStore} from 'tinybase/ui-react'; Then we configure this component to be used for the `name` `Cell` in the table, and capitalize the name at the top of the column: const customCells = {name: {label: 'Name', component: DictionaryCell}}; And apply that configuration to the table via the `customCells` prop: <TableInHtmlTable tableId='genres' headerRow={false} idColumn={false} /> + <TableInHtmlTable tableId='genres' customCells={customCells} /> </> Finally, we can give the links some default styling. a { color: inherit; } Take a look at the `TableInHtmlTableProps` type and `HtmlTableProps` type to see all the ways in which you can configure this component, and again, click the 'CodePen' link under the demo above to try them out. Let's move on to a slightly more complex component in the <SortedTableInHtmlTable /> demo. --- ## Page: https://tinybase.org/demos/ui-components/sortedtableinhtmltable/ In this demo, we showcase the `SortedTableInHtmlTable` component, a more complex and interactive way to render a TinyBase `Table`. Rather than building the whole demo and boilerplate from scratch, we're making changes to the <TableInHtmlTable /> demo to support this new component. ### Set Up We switch out the `TableInHtmlTable` component and import the `SortedTableInHtmlTable` component instead. -import {TableInHtmlTable} from 'tinybase/ui-react-dom'; +import {SortedTableInHtmlTable} from 'tinybase/ui-react-dom'; This component is best showcased with a larger data set, so we load up movies instead of genres: useMemo(async () => { - await loadTable(store, 'genres'); + await loadTable(store, 'movies'); setIsLoading(false); }, []); ### Using the `SortedTableInHtmlTable` Component The `SortedTableInHtmlTable` component is similar to the `TableInHtmlTable` component, requiring at least the `Table` `Id`: const Body = () => { return ( <>- <TableInHtmlTable tableId='genres' /> - <TableInHtmlTable tableId='genres' headerRow={false} idColumn={false} /> - <TableInHtmlTable tableId='genres' customCells={customCells} /> + <SortedTableInHtmlTable + tableId='movies' + /> </> ); }; Take a look at the `SortedTableInHtmlTableProps` type and `HtmlTableProps` type to see all the ways in which you can configure this component. We're going to use a few here. Firstly, since the `Table` is very wide (and contains a lengthy description), we will first explicitly set the `Cell` `Ids` we want to display: -const customCells = {name: {label: 'Name', component: DictionaryCell}}; +const customCells = {name: 'Name', year: 'Year', rating: 'Rating'}; (This configuration can simply be an array of the `Cell` `Ids`, an object with `Cell` `Id` as key and label as value (like this), or an object made up of `CustomCell` objects. See the `HtmlTableProps` type for more details.) <SortedTableInHtmlTable tableId='movies' + customCells={customCells} /> The `SortedTableInHtmlTableProps` component, much like the `getSortedRowIds` method, can take props to indicate how the sorting should work. `cellId` indicates which `Cell` to use to sort on, and `descending` indicates the direction. We can sort the movies by rating accordingly: <SortedTableInHtmlTable tableId='movies' customCells={customCells} + cellId='rating' + descending={true} + limit={7} /> Note that we can also use the `limit` prop to paginate the data. The component automatically adds two classes to the heading of the column that is being used for the sorting. We can add styling to show which column it is, and a small arrow to indicate the direction: th.sorted { background: #ddd; } ### Interactivity The `SortedTableInHtmlTable` component can be made to be interactive. By adding the sortOnClick flag prop, you can make it such that users can click on the column headings to change the sorting: <SortedTableInHtmlTable tableId='movies' customCells={customCells} cellId='rating' descending={true} limit={7} + sortOnClick={true} /> As this means the table's content can change, the columns might adjust their widths and the table jumps around. We can quickly fix this by hinting about the widths of the header row so that the layout is stable: thead th { width: 5rem; &:nth-of-type(2) { width: 28rem; } } Nice! It's still a simple table, but we have some useful interactivity out of the box. We can also add pagination controls, by adding the `paginator` prop. This either takes `true` to enable the default `SortedTablePaginator` component, or a paginator component of your own design that accepts `SortedTablePaginatorProps`. <SortedTableInHtmlTable tableId='movies' customCells={customCells} cellId='rating' descending={true} limit={7} sortOnClick={true} + paginator={true} /> This places the pagination controls in the `<caption>` element of the `<table>`, and you can use CSS to position and style it. We are removing the default text from table caption { caption-side: top; text-align: left; margin-bottom: 1rem; button { margin-right: 0.5rem; } } As well as rendering raw `Tables` from a `Store`, we can do the same for each `Slice` of an `Indexes` object - as you'll see in the next <SliceInHtmlTable /> demo. --- ## Page: https://tinybase.org/demos/ui-components/sliceinhtmltable/ In this demo, we showcase the `SliceInHtmlTable` component, a way to display the `Slice` portions of an `Index`. Rather than building the whole demo and boilerplate from scratch, we're going back and making changes to the <TableInHtmlTable /> demo to demonstrate this new component. Basically, where we previously displayed a `Table` from a `Store`, we are now creating an `Index` and displaying one of its `Slice` objects. ### Set Up We switch out the `TableInHtmlTable` component and import the `SliceInHtmlTable` component instead. We'll also need the `createIndexes` function and `useCreateIndexes` hook: -import {createStore} from 'tinybase'; +import {createIndexes, createStore} from 'tinybase'; -import {Provider, useCell, useCreateStore} from 'tinybase/ui-react'; +import {Provider, useCell, useCreateIndexes, useCreateStore} from 'tinybase/ui-react'; -import {TableInHtmlTable} from 'tinybase/ui-react-dom'; +import {SliceInHtmlTable} from 'tinybase/ui-react-dom'; We need to define the `Index` we are going to use. In the main `App` component, we can create the memoized `Indexes` object, and index the genres by the length of their name. const store = useCreateStore(createStore); +const indexes = useCreateIndexes(store, (store) => + createIndexes(store).setIndexDefinition( + 'genresByNameLength', + 'genres', + (getCell) => 'length ' + getCell('name').length, + ), +); ...and expose it into the app-wide context: return ( - <Provider store={store}>{isLoading ? <Loading /> : <Body />}</Provider> + <Provider store={store} indexes={indexes}> + {isLoading ? <Loading /> : <Body />} + </Provider> ); ### Using the `SliceInHtmlTable` Component The `SliceInHtmlTable` component is very similar to the `TableInHtmlTable` component, but instead of taking a tableId, we provide it with the indexId and the sliceId we want to display (here, the names that are 6 letters long): const Body = () => { return ( - <> - <TableInHtmlTable tableId='genres' /> - <TableInHtmlTable tableId='genres' headerRow={false} idColumn={false} /> - <TableInHtmlTable tableId='genres' customCells={customCells} /> - </> + <SliceInHtmlTable indexId='genresByNameLength' sliceId='length 6' /> ); }; For fun we could add the 'editable' prop to this table, but of course as soon as you add to or delete from the name of the genre, it will get reindexed into a different `Slice` and disappear! So maybe not. Take a look at the `SliceInHtmlTableProps` type and `HtmlTableProps` type to see all the ways in which you can configure this component, and again, click the 'CodePen' link under the demo above to try them out. As well as displaying a `Slice` from an `Indexes` object, you can also render the links between `Tables` with a `Relationships` object, as you'll see next up in the <RelationshipInHtmlTable /> demo. --- ## Page: https://tinybase.org/demos/ui-components/relationshipinhtmltable/ * TinyBase * Demos * UI Components * <RelationshipInHtmlTable /> In this demo, we showcase the `RelationshipInHtmlTable` component, a way to display the two `Tables` linked together by a `Relationship`. Rather than building the whole demo and boilerplate from scratch, we're going back and making changes to the <TableInHtmlTable /> demo again to demonstrate this new component. Basically, where we previously displayed a `Table` from a `Store`, we are now creating an `Relationships` object and displaying one a defined `Relationship` from it. ### Set Up We switch out the `TableInHtmlTable` component and import the `RelationshipInHtmlTable` component instead. We'll also need the `createRelationships` function and `useCreateRelationships` hook: -import {createStore} from 'tinybase'; +import {createRelationships, createStore} from 'tinybase'; -import {Provider, useCell, useCreateStore} from 'tinybase/ui-react'; +import { + CellView, + Provider, + useCell, + useCreateRelationships, + useCreateStore, +} from 'tinybase/ui-react'; -import {TableInHtmlTable} from 'tinybase/ui-react-dom'; +import {RelationshipInHtmlTable} from 'tinybase/ui-react-dom'; We need to define the `Relationship` we are going to use. For the sake of this demo we are going to hand-create a second table which the genres table links to to get extra metadata. Note how metadata is missing for genre 13, 'Music', and so that is empty in the table. useMemo(async () => { await loadTable(store, 'genres'); + store.setTable('metadata', { + g01_meta: {text: 'Dramatic movies to make you think', popularity: 6}, + g02_meta: {text: 'These ones make you laugh', popularity: 7}, + g03_meta: {text: 'Fun for all the family', popularity: 8}, + g04_meta: {text: 'For the romantics at heart', popularity: 5}, + g05_meta: {text: 'From cartoons to CGI', popularity: 5}, + g06_meta: {text: 'Escape to another world', popularity: 4}, + g07_meta: {text: 'Tales of the American West', popularity: 3}, + g08_meta: {text: 'Stay on the edge of your seat', popularity: 6}, + g09_meta: {text: 'For your inner explorer', popularity: 7}, + g10_meta: {text: 'Fast-paced action from start to finish', popularity: 8}, + g11_meta: {text: 'Jump scares to give you nightmares', popularity: 6}, + g12_meta: {text: 'Murders and mysteries', popularity: 5}, + g14_meta: {text: 'Take a step back in time', popularity: 3}, + g15_meta: {text: 'A glimpse of the future', popularity: 8}, + g16_meta: {text: 'Who did it?', popularity: 5}, + }); setIsLoading(false); }, []); In the main `App` component, we can create the memoized `Relationships` object, and create the relationship between the the genres `Table` and the 'remote' metadata `Table`. Note that we concatenate the genre `Id` and '\_meta' to link the rows from the two tables together. const store = useCreateStore(createStore); +const relationships = useCreateRelationships(store, (store) => + createRelationships(store).setRelationshipDefinition( + 'genresMetadata', + 'genres', + 'metadata', + (_, rowId) => rowId + '_meta', + ), +); We expose the `Relationships` object into the app-wide context: return ( - <Provider store={store}>{isLoading ? <Loading /> : <Body />}</Provider> + <Provider store={store} relationships={relationships}> + {isLoading ? <Loading /> : <Body />} + </Provider> ); ### Using the `RelationshipInHtmlTable` Component The `RelationshipInHtmlTable` component is very similar to the `TableInHtmlTable` component, but instead of taking a tableId, we provide it with the relationshipId: const Body = () => { return ( - <> - <TableInHtmlTable tableId='genres' /> - <TableInHtmlTable tableId='genres' headerRow={false} idColumn={false} /> - <TableInHtmlTable tableId='genres' customCells={customCells} /> - </> + <RelationshipInHtmlTable relationshipId='genresMetadata' /> ); }; Note how both the local ('genre') and remote ('metadata') `Ids` and columns are shown in the table with 'dotted pair' column names. Also note how the `Row` with `Id` of 13 has empty content since there is no remote `Row` for the `Relationship` to join to. In reality you are quite likely to want to customize the columns of a RelationshipInHtmlTable. We can use the `customCells` prop for this, using the dotted pair syntax (of `Table` `Id` and `Row` `Id`) to indicate their order, labels, and rendering: - <RelationshipInHtmlTable relationshipId='genresMetadata' /> + <RelationshipInHtmlTable + relationshipId='genresMetadata' + customCells={customRelationshipCells} + idColumn={false} + /> ); }; The customized column ordering and rendering can be a constant, including our custom `Cell` component called Popularity that simply emboldens that number: const Popularity = (props) => ( <b> <CellView {...props} /> </b> ); const customRelationshipCells = { 'genres.name': 'Genre', 'metadata.popularity': { label: 'Popularity', component: Popularity, }, 'metadata.text': 'Description', }; As per usual, take a look at the `RelationshipInHtmlTableProps` type and `HtmlTableProps` type to see all the other ways in which you can configure this component, and again, click the 'CodePen' link under the demo above to try them out. As well as displaying a `Relationship` from an `Indexes` object, you can also render the `ResultTable` of a `Queries` object, as you'll see next up in the <ResultTableInHtmlTable /> demo. --- ## Page: https://tinybase.org/demos/ui-components/resulttableinhtmltable/ In this demo, we showcase the `ResultTableInHtmlTable` component, a way to display the results of a query. Rather than building the whole demo and boilerplate from scratch, we're going back and making changes to the <TableInHtmlTable /> demo to demonstrate this new component. Basically, where we previously displayed a `Table` from a `Store`, we are now displaying the results of a query from a `Queries` object. ### Set Up We switch out the `TableInHtmlTable` component and import the `ResultTableInHtmlTable` component instead. We'll also need the `createQueries` function and `useCreateQueries` hook: -import {createStore} from 'tinybase'; +import {createQueries, createStore} from 'tinybase'; -import {Provider, useCell, useCreateStore} from 'tinybase/ui-react'; +import {Provider, useCell, useCreateQueries, useCreateStore} from 'tinybase/ui-react'; -import {TableInHtmlTable} from 'tinybase/ui-react-dom'; +import {ResultTableInHtmlTable} from 'tinybase/ui-react-dom'; We need to define the query we are going to use. In the main `App` component, we can create the memoized `Queries` object, query for genres starting with the letter 'A' (and the length of the word)... const store = useCreateStore(createStore); +const queries = useCreateQueries(store, (store) => + createQueries(store).setQueryDefinition( + 'genresStartingWithA', + 'genres', + ({select, where}) => { + select('name'); + select((getCell) => getCell('name').length).as('length'); + where((getCell) => getCell('name').startsWith('A')); + }, + ), +); ...and expose it into the app-wide context: return ( - <Provider store={store}>{isLoading ? <Loading /> : <Body />}</Provider> + <Provider store={store} queries={queries}> + {isLoading ? <Loading /> : <Body />} + </Provider> ); ### Using the `ResultTableInHtmlTable` Component The `ResultTableInHtmlTable` component is very similar to the `TableInHtmlTable` component, but instead of taking a tableId, we provide it with the queryId: const Body = () => { return ( - <> - <TableInHtmlTable tableId='genres' /> - <TableInHtmlTable tableId='genres' headerRow={false} idColumn={false} /> - <TableInHtmlTable tableId='genres' customCells={customCells} /> - </> + <ResultTableInHtmlTable queryId='genresStartingWithA' /> ); }; Hopefully that is eminently straightforward! You won't be surprised to learn that you can also use sorting and interactivity on query results, and for that, let's look at the next <ResultSortedTableInHtmlTable /> demo. --- ## Page: https://tinybase.org/demos/ui-components/resultsortedtableinhtmltable/ In this demo, we showcase the `ResultSortedTableInHtmlTable` component, a more complex and interactive way to render the results of a query. Rather than building the whole demo and boilerplate from scratch, we're making changes to the <SortedTableInHtmlTable /> demo to support this new component. Basically, where we previously displayed a `Table` from a `Store`, we are now displaying the result of a query from a `Queries` object. ### Set Up We switch out the `TableInHtmlTable` component and import the `SortedTableInHtmlTable` component instead. -import {createStore} from 'tinybase'; +import {createQueries, createStore} from 'tinybase'; -import {Provider, useCell, useCreateStore} from 'tinybase/ui-react'; +import {Provider, useCell, useCreateQueries, useCreateStore} from 'tinybase/ui-react'; -import {SortedTableInHtmlTable} from 'tinybase/ui-react-dom'; +import {ResultSortedTableInHtmlTable} from 'tinybase/ui-react-dom'; We need to register the query we are going to use. In the main `App` component, we can create the memoized `Queries` object, query a few columns for movies made since 2018... const store = useCreateStore(createStore); +const queries = useCreateQueries(store, (store) => + createQueries(store).setQueryDefinition( + 'recentMovies', + 'movies', + ({select, where}) => { + select('name').as('Name'); + select('year').as('Year'); + select('rating').as('Rating'); + where((getCell) => getCell('year') >= 2018); + }, + ), +); ...and expose it into the app-wide context: return ( - <Provider store={store}>{isLoading ? <Loading /> : <Body />}</Provider> + <Provider store={store} queries={queries}> + {isLoading ? <Loading /> : <Body />} + </Provider> ); ### Using the `ResultSortedTableInHtmlTable` Component The `ResultSortedTableInHtmlTable` component is very similar to the `SortedTableInHtmlTable` component, but instead of taking a tableId, we provide it with the queryId: const Body = () => { return ( <>- <SortedTableInHtmlTable - tableId='movies' - customCells={customCells} - cellId='rating' + <ResultSortedTableInHtmlTable + queryId='recentMovies' + cellId='Rating' descending={true} limit={7} sortOnClick={true} paginator={true} /> </> ); }; Note that we explicitly picked and labelled which columns were in the query, so we don't need the `customCells` prop any more. -const customCells = {name: 'Name', year: 'Year', rating: 'Rating'}; We are using the same `sortOnClick` props from the <SortedTableInHtmlTable /> demo so you should find it to be interactive just as before. Let's next look at components that let you edit `Store` data, starting with the <EditableValueView /> demo. --- ## Page: https://tinybase.org/demos/ui-components/editablevalueview/ In this demo, we showcase the `EditableValueView` component, which allows you to edit `Values` in the `Store` in a web environment. Rather than building the whole demo and boilerplate from scratch, we're making changes to the <ValuesInHtmlTable /> demo to show this new component. ### Set Up We start off by simply adding the component to the imports: -import {ValuesInHtmlTable} from 'tinybase/ui-react-dom'; +import {EditableValueView, ValuesInHtmlTable} from 'tinybase/ui-react-dom'; ### Using the `EditableValueView` Component The `EditableValueView` component simply needs the valueId to render and make editable. We replace one of the tables from the original demo to add the control: const Body = () => { return ( <> <ValuesInHtmlTable /> - <ValuesInHtmlTable headerRow={false} idColumn={false} /> + <div id='edit'> + Username: + <EditableValueView valueId='username' /> + </div> </> ); }; We can style its container and the button that lets you change type: #edit { background: white; box-shadow: 0 0 1rem #0004; margin: 2rem; min-width: 16rem; padding: 0.5rem 1rem 1rem; } .editableValue { button { width: 4rem; margin-right: 0.5rem; } } And finally, we can enable the `editable` prop on the original `ValuesInHtmlTable` component so that it uses this view for its own rendering: - <ValuesInHtmlTable /> + <ValuesInHtmlTable editable={true}/> And just like that you have an internally consistent editing experience! There's a very similar component for Cells that we will now explore in the equivalent <EditableCellView /> demo. --- ## Page: https://tinybase.org/demos/ui-components/editablecellview/ In this demo, we showcase the `EditableCellView` component, which allows you to edit `Cell` values in the `Store` in a web environment. Rather than building the whole demo and boilerplate from scratch, we're making changes to the <TableInHtmlTable /> demo to show this new component. ### Set Up We start off by simply adding the component to the imports: -import {TableInHtmlTable} from 'tinybase/ui-react-dom'; +import {EditableCellView, TableInHtmlTable} from 'tinybase/ui-react-dom'; ### Using the `EditableCellView` Component The `EditableCellView` component simply needs a tableId, rowId, and cellId to render the `Cell` and make it editable. We replace two of the tables from the original demo to add the control: - <TableInHtmlTable tableId='genres' headerRow={false} idColumn={false} /> - <TableInHtmlTable tableId='genres' customCells={customCells} /> + <div id='edit'> + Genre 5 name: + <EditableCellView tableId='genres' rowId='g05' cellId='name' /> + </div> </> ); }; We can style its container and the button that lets you change type: #edit { align-self: flex-start; background: white; box-shadow: 0 0 1rem #0004; margin: 2rem; min-width: 16rem; padding: 0.5rem 1rem 1rem; } .editableCell { button { width: 4rem; margin-right: 0.5rem; } } And finally, we can enable the `editable` prop on the original `TableInHtmlTable` component so that it uses this view for its own rendering: - <TableInHtmlTable tableId='genres' /> + <TableInHtmlTable tableId='genres' editable={true}/> And again, we now have editable data across the `Table`! We finish off the demos of the UI components with the debugging tool. Let's proceed to the <Inspector /> demo. --- ## Page: https://tinybase.org/demos/ui-components/inspector/ In this demo, we showcase the `Inspector` component, which allows you to view and edit the content of a `Store` in a debug web environment. Let's make changes to the <TableInHtmlTable /> demo so we can start with a well-populated `Store` to inspect. ### Set Up Let's import the `Inspector` component: <script type="importmap"> { "imports": { "tinybase": "https://esm.sh/tinybase@6.0.1", "tinybase/ui-react": "https://esm.sh/tinybase@6.0.1/ui-react", - "tinybase/ui-react-dom": "https://esm.sh/tinybase@6.0.1/ui-react-dom", + "tinybase/ui-react-inspector": "https://esm.sh/tinybase@6.0.1/ui-react-inspector", "react": "https://esm.sh/react@^19.0.0", "react/jsx-runtime": "https://esm.sh/react@^19.0.0/jsx-runtime", "react-dom/client": "https://esm.sh/react-dom@^19.0.0/client" } } </script> We're going to use the `useTableIds` hook briefly, and the Inspector from the `ui-react-inspector` module: -import {Provider, useCell, useCreateStore} from 'tinybase/ui-react'; +import {Provider, useCreateStore, useTableIds} from 'tinybase/ui-react'; -import {TableInHtmlTable} from 'tinybase/ui-react-dom'; +import {Inspector} from 'tinybase/ui-react-inspector'; The inspector component is best showcased with a larger data set, so we load up all four tables of the movie database data: useMemo(async () => { - await loadTable(store, 'genres'); + store.startTransaction(); + await Promise.all( + ['movies', 'genres', 'people', 'cast'].map((tableId) => + loadTable(store, tableId), + ), + ); + store.finishTransaction(); setIsLoading(false); }, []); Let's update the body of the app to show some very basic data about the `Store`: const Body = () => { return ( <>- <TableInHtmlTable tableId='genres' /> - <TableInHtmlTable tableId='genres' headerRow={false} idColumn={false} /> - <TableInHtmlTable tableId='genres' customCells={customCells} /> + <div id='info'> + Loaded tables: {useTableIds().join(', ')} + </div> </> ); }; #info { align-self: center; } OK, that's not much of an app! But at least we can now instantiate the `Inspector` component. ### Using the `Inspector` Component The `Inspector` component can appear anywhere in the app's virtual DOM and will appear as an overlay. It is added to an app like so: <div id='info'> Loaded tables: {useTableIds().join(', ')} </div> + <Inspector open={true} /> </> ); };