In this demo, we build a simple app that uses React and a simple Store
object to load and display country data.
First, we pull in React, ReactDOM, and TinyBase:
<script src="/umd/react.production.min.js"></script>
<script src="/umd/react-dom.production.min.js"></script>
<script src="/umd/tinybase.js"></script>
<script src="/umd/ui-react.js"></script>
We import the functions and components we need:
const {
createLocalPersister,
createRemotePersister,
createSessionPersister,
createIndexes,
createStore,
defaultSorter,
} = TinyBase;
const {
CellView,
IndexView,
Provider,
SliceView,
useCell,
useCreateIndexes,
useCreatePersister,
useCreateStore,
useDelCellCallback,
useRow,
useSetCellCallback,
useSetRowCallback,
useSliceRowIds,
} = TinyBaseUiReact;
const {useCallback} = React;
We also set up some string constants for showing star emojis:
const STAR = '\u2605';
const UNSTAR = '\u2606';
We have a top-level Demo
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:
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, persisted to local storage.sessionStore
contains the Id
of an Indexes
object, the Id
of an index, and the Id
of a slice, persisted 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 Demo = () => {
const countryStore = useCreateStore(() =>
createStore().setSchema({
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().setSchema({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 sessionStore = useCreateStore(createStore);
useCreatePersister(
sessionStore,
(store) => createSessionPersister(store, 'countries/sessionStore'),
[],
async (persister) => {
await persister.startAutoLoad({
ui: {
currentSlice: {
indexes: 'countryIndexes',
indexId: 'firstLetter',
sliceId: 'A',
},
},
});
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, sessionStore}}
indexesById={{countryIndexes, starIndexes}}
>
<Filters />
<Countries />
</Provider>
);
};
We also use a simple grid layout to arrange the app:
@accentColor: #d81b60;
@spacing: 0.5rem;
@border: 1px solid #ccc;
@font-face {
font-family: Lato;
src: url(https://tinybase.org/fonts/lato-light.woff2) format('woff2');
}
body {
box-sizing: border-box;
display: flex;
font-family: Lato, sans-serif;
margin: 0;
height: 100vh;
padding: @spacing * 2;
text-align: center;
}
Finally, when the window loads, we render the Demo
component into the demo div
to start the app:
window.addEventListener('load', () => ReactDOM.render(<Demo />, document.body));
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 sessionStore
object 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 currentSlice
Row
of the ui
Table
of the sessionStore
:
const useCurrentSlice = () => useRow('ui', 'currentSlice', 'sessionStore');
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) =>
useSetRowCallback(
'ui',
'currentSlice',
() => ({indexes, indexId, sliceId}),
[indexes, indexId, sliceId],
'sessionStore',
);
Filters
ComponentThis 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;
margin-right: @spacing;
padding-right: @spacing;
.filter {
cursor: pointer;
&.current {
color: @accentColor;
}
.label,
.count {
display: inline-block;
width: 2em;
}
.count {
color: #777;
font-size: 0.8rem;
text-align: left;
}
}
}
Countries
ComponentThe 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;
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.