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/index.js"></script>
<script src="/umd/tinybase/ui-react/index.js"></script>
<script src="/umd/tinybase/ui-react-dom/index.js"></script>
<script src="/umd/tinybase/ui-react-inspector/index.js"></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:

const {createQueries, createStore} = TinyBase;
const {Provider, useCreateStore} = TinyBaseUiReact;
const {createElement, useMemo, useState} = React;
const {Inspector} = TinyBaseUiReactInspector;
const {SortedTableInHtmlTable} = TinyBaseUiReactDom;

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', () =>
  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 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.