TinyBase logoTinyBase

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

As per usual, we first 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 need the following parts of the TinyBase API, the ui-react module, and React itself:

const {createQueries, createStore} = TinyBase;
const {CellView, Provider, SortedTableView, useCreateStore, useRowIds} =
  TinyBaseUiReact;
const {createElement, useCallback, useMemo, useState} = React;

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 />}
    </Provider>
  );
}

With simple boilerplate code to load the component, off we go:

addEventListener('load', () =>
  ReactDOM.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 COLUMNS = [
  '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. It's fully self-contained in terms of managing its own state, and wraps the underlying SortedTableView provided by the TinyBase ui-react module.

First we create three items of state for the table: the column being sorted by, whether it is descending or not, and the offset for the pagination. We also get the total size of the Table.

const CityTable = () => {
  const [sortCellId, setSortCellId] = useState('Population');
  const [descending, setDescending] = useState(true);
  const [offset, setOffset] = useState(0);
  const count = useRowIds('cities').length;
  // ...

Next we create the pagination strip that lists the total number, and shows buttons for paginating up and down through sets of 10 records from the Table.

// ...
const LIMIT = 10;

const Pagination = useCallback(
  () => (
    <>
      {count} cities
      {offset > 0 ? (
        <button className="prev" onClick={() => setOffset(offset - LIMIT)} />
      ) : (
        <button className="prev disabled" />
      )}
      {offset + LIMIT < count ? (
        <button className="next" onClick={() => setOffset(offset + LIMIT)} />
      ) : (
        <button className="next disabled" />
      )}
      {offset + 1} to {Math.min(count, offset + LIMIT)}
    </>
  ),
  [count, offset],
);
// ...

Next, the table itself. There is a heading component of the columns, where each heading cell can be clicked to sort (or reverse sort) that column:

// ...
const HeadingComponent = useCallback(
  () => (
    <tr>
      {COLUMNS.map((cellId, c) =>
        cellId == sortCellId ? (
          <th onClick={() => setDescending(!descending)} className={`col${c}`}>
            {descending ? '\u2193' : '\u2191'} {cellId}
          </th>
        ) : (
          <th onClick={() => setSortCellId(cellId)} className={`col${c}`}>
            {cellId}
          </th>
        ),
      )}
    </tr>
  ),
  [sortCellId, descending],
);
// ...

We put these together to create the paginated table:

// ...
  return (
    <>
      <Pagination />
      <table>
        <HeadingComponent />
        <SortedTableView
          tableId="cities"
          cellId={sortCellId}
          descending={descending}
          offset={offset}
          limit={LIMIT}
          rowComponent={CityRow}
        />
      </table>
    </>
  );
};

The CityRow component used above is very simple. Each row of the table simply renders the Cells from the underlying data Table:

// ...
const CityRow = (props) => (
  <tr>
    {COLUMNS.map((cellId) => (
      <td>
        <CellView {...props} cellId={cellId} />
      </td>
    ))}
  </tr>
);

The table benefits from some light styling for the pagination buttons and the table itself:

button {
  border: 0;
  cursor: pointer;
  height: 1rem;
  padding: 0;
  vertical-align: text-top;
  width: 1rem;
  &.prev {
    margin-left: 0.5rem;
    &::before {
      content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 0 100 100" fill="black"><path d="M65 20v60l-30-30z" /></svg>');
    }
  }
  &.next {
    margin-right: 0.5rem;
    &::before {
      content: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" height="1rem" viewBox="0 0 100 100" fill="black"><path d="M35 20v60l30-30z" /></svg>');
    }
  }
  &.disabled {
    cursor: default;
    opacity: 0.3;
  }
}

table {
  border-collapse: collapse;
  font-size: inherit;
  line-height: inherit;
  margin-top: 0.5rem;
  table-layout: fixed;
  width: 100%;
  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;
    &.col0 {
      width: 25%;
    }
    &.col1,
    &.col2,
    &.col3,
    &.col4,
    &.col5 {
      width: 15%;
    }
  }
  td {
    border-bottom: 1px solid #eee;
  }
}

That's it for the JavaScript!

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.