IndexDB is a document- oriented DBMS, a convenient tool for temporary storage of a relatively small amount (one and tens of megabytes) of structured data on the browser side. The standard task for which I have to use IndexDB includes caching data of business directories on the client side (names of countries, cities, currencies by code, etc.). After copying them to the client side, you can only occasionally download updates from these directories from the server (or the whole - they are small) and not do this every time you open the browser window.
There are non-standard, very controversial, but working ways to use IndexDB:
- caching data for all business objects so that the browser-side can use extensive sorting and filtering capabilities
- storing application status in IndexDB instead of Redux Store
Three key differences between IndexDB and Redux Store are important to us:
- IndexDB is an external storage that is not cleared when leaving the page. In addition, it is the same for multiple open tabs (which sometimes leads to somewhat unexpected behavior)
- IndexDB is a completely asynchronous DBMS. All operations - opening, reading, writing, searching - are asynchronous.
- IndexDB cannot (in a trivial way) be stored in JSON and use brain-tricking techniques from Redux to create Snapshots, ease of debugging, and a journey into the past.
Step 0: task list
A classic example with a task list. Variant with state storage in the state of the current and only component
Implementation of a task list component with storing the list in component state
import React, { PureComponent } from 'react'; import Button from 'react-bootstrap/Button'; import counter from 'common/counter'; import Form from 'react-bootstrap/Form'; import Table from 'react-bootstrap/Table'; export default class Step0 extends PureComponent { constructor() { super( ...arguments ); this.state = { newTaskText: '', tasks: [ { id: counter(), text: 'Sample task' }, ], }; this.handleAdd = () => { this.setState( state => ( { tasks: [ ...state.tasks, { id: counter(), text: state.newTaskText } ], newTaskText: '', } ) ); }; this.handleDeleteF = idToDelete => () => this.setState( state => ( { tasks: state.tasks.filter( ( { id } ) => id !== idToDelete ), } ) ); this.handleNewTaskTextChange = ( { target: { value } } ) => this.setState( { newTaskText: value || '', } ); } render() { return <Table bordered hover striped> <thead><tr> <th>#</th><th>Text</th><th /> </tr></thead> <tbody> { this.state.tasks.map( task => <tr key={task.id}> <td>{task.id}</td> <td>{task.text}</td> <td><Button onClick={this.handleDeleteF( task.id )} type="button" variant="danger"></Button></td> </tr> ) } <tr key="+1"> <td /> <td><Form.Control onChange={this.handleNewTaskTextChange} placeholder=" " type="text" value={this.state.newTaskText || ''} /></td> <td><Button onClick={this.handleAdd} type="button" variant="primary"></Button></td> </tr> </tbody> </Table>; } }
( github source code ) So far, all operations with tasks are synchronous. If adding a task takes 3 seconds, then the browser will just freeze for 3 seconds. Of course, while we keep everything in our memory, we can not think about it. When we include processing with a server or with a local database, we will have to take care of the beautiful processing of asynchrony as well. For example, blocking work with a table (or individual elements) while adding or removing elements.
In order not to repeat the description of the UI in the future, we will place it in a separate TaskList component, the only task of which will generate the HTML code of the task list. At the same time, we will replace the usual buttons with a special wrapper around the bootstrap Button, which will block the button until the button handler completes its execution, even if this handler is an asynchronous function.
Implementing a component storing a task list in react state
import React, { PureComponent } from 'react'; import counter from 'common/counter'; import TaskList from '../common/TaskList'; export default class Step01 extends PureComponent { constructor() { super( ...arguments ); this.state = { tasks: [ { id: counter(), text: 'Sample task' }, ] }; this.handleAdd = newTaskText => { this.setState( state => ( { tasks: [ ...state.tasks, { id: counter(), text: newTaskText } ], } ) ); }; this.handleDelete = idToDelete => this.setState( state => ( { tasks: state.tasks.filter( ( { id } ) => id !== idToDelete ), } ) ); } render() { return <> <h1> </h1> <h2> </h2> <TaskList onAdd={this.handleAdd} onDelete={this.handleDelete} tasks={this.state.tasks} /> </>; } }
( github source code ) Implementing a component that displays a list of tasks and contains a form for adding a new
import React, { PureComponent } from 'react'; import Button from './AutoDisableButtonWithSpinner'; import Form from 'react-bootstrap/Form'; import Table from 'react-bootstrap/Table'; export default class TaskList extends PureComponent { constructor() { super( ...arguments ); this.state = { newTaskAdding: false, newTaskText: '', }; this.handleAdd = async() => { this.setState( { newTaskAdding: true } ); try { // , await this.props.onAdd( this.state.newTaskText ); this.setState( { newTaskText: '' } ); } finally { this.setState( { newTaskAdding: false } ); } }; this.handleDeleteF = idToDelete => async() => await this.props.onDelete( idToDelete ); this.handleNewTaskTextChange = ( { target: { value } } ) => this.setState( { newTaskText: value || '', } ); } render() { return <Table bordered hover striped> <thead><tr> <th>#</th><th>Text</th><th /> </tr></thead> <tbody> { this.props.tasks.map( task => <tr key={task.id}> <td>{task.id}</td> <td>{task.text}</td> <td><Button onClick={this.handleDeleteF( task.id )} type="button" variant="danger"></Button></td> </tr> ) } <tr key="+1"> <td /> <td><Form.Control disabled={this.state.newTaskAdding} onChange={this.handleNewTaskTextChange} placeholder=" " type="text" value={this.state.newTaskText || ''} /></td> <td><Button onClick={this.handleAdd} type="button" variant="primary"></Button></td> </tr> </tbody> </Table>; } }
( github source code ) Already in the sample code you can see the keywords async / await. Async / await constructs can significantly reduce the amount of code that works with Promises. The await keyword allows you to wait for a response from a function returning Promise, as if it were a regular function (instead of waiting for a result in then ()). Of course, an asynchronous function does not magically turn into a synchronous one, and, for example, the execution thread will be interrupted when await is used. But then the code becomes more concise and understandable, and await can be used both in loops and in try / catch / finally constructs.
For example,
TaskList
not just call the
this.props.onAdd
handler, but does it using the
await
keyword. In this case, if the handler is a regular function that will return nothing, or return any value other than
Promise
, then the
TaskList
component
TaskList
simply continue the
handleAdd
method in the usual way. But if the handler returns
Promise
(including if the handler is declared as an async function), then the
TaskList
will wait for the handler to finish executing, and only after that will reset the values of the
newTaskAdding
and
newTaskText
.
Step 1: Add IndexDB to React Component
To simplify our work, first we will write a simple component that implements Promise methods for:
- opening a database with trivial error handling
- search for items in the database
- adding items to the database
The first is the most “non-trivial” - as many as 5 event handlers. However, no rocket science:
openDatabasePromise () - open database
function openDatabasePromise( keyPath ) { return new Promise( ( resolve, reject ) => { const dbOpenRequest = window.indexedDB.open( DB_NAME, '1.0.0' ); dbOpenRequest.onblocked = () => { reject( ' , , ' + ' .' ); }; dbOpenRequest.onerror = err => { console.log( 'Unable to open indexedDB ' + DB_NAME ); console.log( err ); reject( ' , .' + ( err.message ? ' : ' + err.message : '' ) ); }; dbOpenRequest.onupgradeneeded = event => { const db = event.target.result; try { db.deleteObjectStore( OBJECT_STORE_NAME ); } catch ( err ) { console.log( err ); } db.createObjectStore( OBJECT_STORE_NAME, { keyPath } ); }; dbOpenRequest.onsuccess = () => { console.info( 'Successfully open indexedDB connection to ' + DB_NAME ); resolve( dbOpenRequest.result ); }; dbOpenRequest.onerror = reject; } ); }
getAllPromise / getPromise / putPromise - wrapper IndexDb calls in Promise
// ObjectStore, IDBRequest // Promise function wrap( methodName ) { return function() { const [ objectStore, ...etc ] = arguments; return new Promise( ( resolve, reject ) => { const request = objectStore[ methodName ]( ...etc ); request.onsuccess = () => resolve( request.result ); request.onerror = reject; } ); }; } const deletePromise = wrap( 'delete' ); const getAllPromise = wrap( 'getAll' ); const getPromise = wrap( 'get' ); const putPromise = wrap( 'put' ); }
Putting It All Together Into One IndexedDbRepository Class
IndexedDbRepository - wrapper around IDBDatabase
const DB_NAME = 'objectStore'; const OBJECT_STORE_NAME = 'objectStore'; /* ... */ export default class IndexedDbRepository { /* ... */ constructor( keyPath ) { this.error = null; this.keyPath = keyPath; // async // this.openDatabasePromise = this._openDatabase(); } async _openDatabase( keyPath ) { try { this.dbConnection = await openDatabasePromise( keyPath ); } catch ( error ) { this.error = error; throw error; } } async _tx( txMode, callback ) { await this.openDatabasePromise; // await db connection const transaction = this.dbConnection.transaction( [ OBJECT_STORE_NAME ], txMode ); const objectStore = transaction.objectStore( OBJECT_STORE_NAME ); return await callback( objectStore ); } async findAll() { return this._tx( 'readonly', objectStore => getAllPromise( objectStore ) ); } async findById( key ) { return this._tx( 'readonly', objectStore => getPromise( objectStore, key ) ); } async deleteById( key ) { return this._tx( 'readwrite', objectStore => deletePromise( objectStore, key ) ); } async save( item ) { return this._tx( 'readwrite', objectStore => putPromise( objectStore, item ) ); } }
( github source code ) Now you can access IndexDB from the code:
const db = new IndexedDbRepository( 'id' ); // , await db.save( { id: 42, text: 'Task text' } ); const item = await db.findById( 42 ); const items = await db.findAll();
Connect this “repository” to our component. According to the rules of react, a call to the server should be in the componentDidMount () method:
import IndexedDbRepository from '../common/IndexedDbRepository'; /*...*/ componentDidMount() { this.repository = new IndexedDbRepository( 'id' ); // this.repository.findAll().then( tasks => this.setState( { tasks } ) ); }
Theoretically, the
componentDidMount()
function can be declared as async, then async / await constructs can be used instead of then (). But still
componentDidMount()
is not "our" function, but called by React. Who knows how the react 17.x library will behave in response to an attempt to return
Promise
instead of
undefined
?
Now in the constructor, instead of filling it with an empty array (or an array with test data), we will fill it with null. And in render, it will process this null as a need to wait for data processing. Those who wish, in principle, can put this in separate flags, but why produce entities?
constructor() { super( ...arguments ); this.state = { tasks: null }; /* ... */ } /* ... */ render() { if ( this.state.tasks === null ) return <><Spinner animation="border" aria-hidden="true" as="span" role="status" /><span> ...</span></>; /* ... */ }
It remains to implement the
handleAdd
/
handleDelete
:
constructor() { /* ... */ this.handleAdd = async( newTaskText ) => { await this.repository.save( { id: counter(), text: newTaskText } ); this.setState( { tasks: null } ); this.setState( { tasks: await this.repository.findAll() } ); }; this.handleDelete = async( idToDelete ) => { await this.repository.deleteById( idToDelete ); this.setState( { tasks: null } ); this.setState( { tasks: await this.repository.findAll() } ); }; }
In both handlers, we first turn to the repository to add or remove an item, and then we clear the state of the current component and again request a new list from the repository. It seems that the calls to setState () will go one after another. But the await keyword in the last lines of the handlers will cause the second setState () call to happen only after the Promise () obtained from the findAll () method is resolved.
Step 2. Listen to the changes
A big flaw in the code above is that, firstly, the repository is connected in each component. Secondly, if one component changes the contents of the repository, then the other component does not know about it until it rereads the state as a result of any user actions. It is not comfortable.
To combat this, we’ll introduce a new RepositoryListener component and let it do two things. This component, firstly, will be able to subscribe to changes in the repository. Secondly, the RepositoryListener will notify the component that created it about these changes.
First of all, adding the ability to register handlers in IndexedDbRepository:
export default class IndexedDbRepository { /*...*/ constructor( keyPath ) { /*...*/ this.listeners = new Set(); this.stamp = 0; /*...*/ } /*...*/ addListener( listener ) { this.listeners.add( listener ); } onChange() { this.stamp++; this.listeners.forEach( listener => listener( this.stamp ) ); } removeListener( listener ) { this.listeners.delete( listener ); } }
( github source code )
We will pass a stamp to the handlers, which will change with every call to onChange (). And we modify the _tx method so that
onChange()
called for each call in a transaction with
readwrite
mode:
async _tx( txMode, callback ) { await this.openDatabasePromise; // await db connection try { const transaction = this.dbConnection.transaction( [ OBJECT_STORE_NAME ], txMode ); const objectStore = transaction.objectStore( OBJECT_STORE_NAME ); return await callback( objectStore ); } finally { if ( txMode === 'readwrite' ) this.onChange(); // notify listeners } }
( github source code )
If we still used
then()
/
catch()
to work with Promise, we would either have to duplicate the call to
onChange()
, or use special polyfills for Promise () that support
final()
. Fortunately, async / await allows you to do this simply and without unnecessary code.
The RepositoryListener component itself connects an event listener in the componentDidMount and componentWillUnmount methods:
RepositoryListener Code
import IndexedDbRepository from './IndexedDbRepository'; import { PureComponent } from 'react'; export default class RepositoryListener extends PureComponent { constructor() { super( ...arguments ); this.prevRepository = null; this.repositoryListener = repositoryStamp => this.props.onChange( repositoryStamp ); } componentDidMount() { this.subscribe(); } componentDidUpdate() { this.subscribe(); } componentWillUnmount() { this.unsubscribe(); } subscribe() { const { repository } = this.props; if ( repository instanceof IndexedDbRepository && this.prevRepository !== repository ) { if ( this.prevRepository !== null ) { this.prevRepository.removeListener( this.repositoryListener ); } this.prevRepository = repository; repository.addListener( this.repositoryListener ); } } unsubscribe( ) { if ( this.prevRepository !== null ) { this.prevRepository.removeListener( this.repositoryListener ); this.prevRepository = null; } } render() { return this.props.children || null; } }
( github source code ) Now we will include the processing of repository changes in our main component, and, guided by the DRY principle , we will remove the corresponding code from the
handleAdd
/
handleDelete
:
constructor() { super( ...arguments ); this.state = { tasks: null }; this.handleAdd = async( newTaskText ) => { await this.repository.save( { id: counter(), text: newTaskText } ); }; this.handleDelete = async( idToDelete ) => { await this.repository.deleteById( idToDelete ); }; this.handleRepositoryChanged = async() => { this.setState( { tasks: null } ); this.setState( { tasks: await this.repository.findAll() } ); }; } componentDidMount() { this.repository = new IndexedDbRepository( 'id' ); this.handleRepositoryChanged(); // initial load }
( github source code )
And we add a call to handleRepositoryChanged from the connected RepositoryListener:
render() { /* ... */ return <RepositoryListener onChange={this.handleRepoChanged} repository={this.repository}> <TaskList onAdd={this.handleAdd} onDelete={this.handleDelete} tasks={this.state.tasks} /> </RepositoryListener>; }
( github source code )
Step 3. Take out the loading of data and its updating in a separate component
We wrote a component that can receive data from the repository, can change data in the repository. But if you imagine a large project with 100+ components, it turns out that each component that displays data from the repository will be forced to:
- Ensure that the repository is connected correctly from a single point
- Provide initial data loading in
componentDidMount()
method - Connect the
RepositoryListener
component, which provides a handler call to reload changes
Are there too many duplicate actions? It seems not so. And if something is forgotten? Get lost with copy paste?
It would be great to somehow make sure that we write once the rule of getting the list of tasks from the repository, and something magical executes these methods, gives us data, processes changes in the repository, and for the heap it can also connect this repository.
this.doFindAllTasks = ( repo ) => repo.findAll(); /*...*/ <DataProvider doCalc={ this.doFindAllTasks }> {(data) => <span>... -, data...</span>} </DataProvider>
The only non-trivial moment in the implementation of this component is that doFindAllTasks () is Promise. To facilitate our work, we will create a separate component that is waiting for Promise to execute, and calls a descendant with the calculated value:
PromiseComponent Code
( github source code )
import { PureComponent } from 'react'; export default class PromiseComponent extends PureComponent { constructor() { super( ...arguments ); this.state = { error: null, value: null, }; this.prevPromise = null; } componentDidMount() { this.subscribe(); } componentDidUpdate( ) { this.subscribe(); } componentWillUnmount() { this.unsubscribe(); } subscribe() { const { cleanOnPromiseChange, promise } = this.props; if ( promise instanceof Promise && this.prevPromise !== promise ) { if ( cleanOnPromiseChange ) this.setState( { error: null, value: null } ); this.prevPromise = promise; promise.then( value => { if ( this.prevPromise === promise ) { this.setState( { error: null, value } ); } } ) .catch( error => { if ( this.prevPromise === promise ) { this.setState( { error, value: null } ); } } ); } } unsubscribe( ) { if ( this.prevPromise !== null ) { this.prevPromise = null; } } render() { const { children, fallback } = this.props; const { error, value } = this.state; if ( error !== null ) { throw error; } if ( value === undefined || value === null ) { return fallback || null; } return children( value ); } }
( github source code )
This component in its logic and internal structure is very similar to RepositoryListener. Because both the one and the other must “sign”, “listen” to events and somehow process them. And also keep in mind that whose events you need to listen to could change.
Further, the very magic component DataProvider so far looks very simple:
import repository from './RepositoryHolder'; /*...*/ export default class DataProvider extends PureComponent { constructor() { super( ...arguments ); this.handleRepoChanged = () => this.forceUpdate(); } render() { return <RepositoryListener onChange={this.handleRepoChanged} repository={repository}> <PromiseComponent promise={this.props.doCalc( repository )}> {data => this.props.children( data )} </PromiseComponent> </RepositoryListener>; } }
( github source code )
Indeed, they took the repository (and the separate singlenton RepositoryHolder, which is now in import), called doCalc, this will allow you to transfer task data to this.props.children, and therefore, draw a list of tasks. Singlenton also looks simple:
const repository = new IndexedDbRepository( 'id' ); export default repository;
Now replace the database call from the main component with the DataProvider call:
import repository from './RepositoryHolder'; /* ... */ export default class Step3 extends PureComponent { constructor() { super( ...arguments ); this.doFindAllTasks = repository => repository.findAll(); /* ... */ } render() { return <DataProvider doCalc={this.doFindAllTasks} fallback={<><Spinner animation="border" aria-hidden="true" as="span" role="status" /><span> ...</span></>}> { tasks => <TaskList onAdd={this.handleAdd} onDelete={this.handleDelete} tasks={tasks} /> } </DataProvider>; } }
( github source code )
This could stop. It turned out pretty well: we describe the rule for receiving data, and a separate component monitors the actual receipt of this data, as well as the update. There were literally a couple of little things:
- For each data request, somewhere in the code (but not in the
render()
methodrender()
you need to describe the function of accessing the repository, and then pass this function to the DataProvider - The call to the DataProvider is great and quite in the spirit of React, but very ugly from the point of view of JSX. If you have several components, then two or three levels of nesting of different DataProviders will confuse you very much.
- It is sad that receiving data is done in one component (DataProvider), and their change is done in another (main component). I would like to describe this with the same mechanism.
Step 4. connect ()
Familiar with react-redux by the title of the title already guessed. I will make the following hint to the rest: it would be nice if, instead of calling children () with parameters, the DataProvider service component would fill out the properties of our component based on the rules. And if something has changed in the repository, then it would simply change the properties with the standard React mechanism.
For this we will use the Higher Order Component. Actually, it’s nothing complicated, it’s just a function that takes a component class as a parameter and gives another, more complex component. Therefore, our function that we will write will be:
- Take as an argument the component class where to pass the parameters
- Accept a set of rules, how to get data from the repository, and in what properties to put it
- In its use, it will be similar to the connect () function from react-redux .
The call to this function will look like this:
const mapRepoToProps = repository => ( { tasks: repository.findAll(), } ); const mapRepoToActions = repository => ( { doAdd: ( newTaskText ) => repository.save( { id: counter(), text: newTaskText } ), doDelete: ( idToDelete ) => repository.deleteById( idToDelete ), } ); const Step4Connected = connect( mapRepoToProps, mapRepoToActions )( Step4 );
The first lines
Step4
mapping between the name of the properties of the
Step4
component and the values that will be loaded from the repository. Next comes the mapping for actions: what will happen if
Step4
call
this.props.doAdd(...)
or
this.props.doDelete(...)
from within the
Step4
component. And the last line brings everything together and calls the connect function. The result is a new component (which is why this technique is called Higher Order Component). And we will no longer export from the file the original Step4 component, but the wrapper around it:
/*...*/ class Step4 extends PureComponent { /*...*/ } /*...*/ const Step4Connected = connect( /*...*/ )( Step4 ); export default Step4Connected;
And the component of working with TaskList now looks like a simple wrapper:
class Step4 extends PureComponent { render() { return this.props.tasks === undefined || this.props.tasks === null ? <><Spinner animation="border" aria-hidden="true" as="span" role="status" /><span> ...</span></> : <TaskList onAdd={this.props.doAdd} onDelete={this.props.doDelete} tasks={this.props.tasks} />; } }
( github source code )
And that’s it. No constructors, no additional handlers - everything is placed by the connect () function in the props of the component.
It remains to see how the connect () function should look.
Connect () code
import repository from './RepositoryHolder'; /* ... */ class Connected extends PureComponent { constructor() { super( ...arguments ); this.handleRepoChanged = () => this.forceUpdate(); } render() { const { childClass, childProps, mapRepoToProps, mapRepoToActions } = this.props; const promises = mapRepoToProps( repository, childProps ); const actions = mapRepoToActions( repository, childProps ); return <RepositoryListener onChange={this.handleRepoChanged} repository={repository}> <PromisesComponent promises={promises}> { values => React.createElement( childClass, { ...childProps, ...values, ...actions, } )} </PromisesComponent> </RepositoryListener>; } } export default function connect( mapRepoToProps, mapRepoToActions ) { return childClass => props => <Connected childClass={childClass} childProps={props} mapRepoToActions={mapRepoToActions} mapRepoToProps={mapRepoToProps} />; }
( github source code ) The code also seems not very complicated ... although if you start diving, questions will arise. You need to read from the end. It is there that the same
connect()
function is defined. It takes two parameters, and then returns a function that returns ... again a function? Not really. The last construction of
props => <Connected...
returns not just a function, but a functional React component . Thus, when we embed ConnectedStep4 into the virtual tree, it will include:
- parent -> anonymous functional component -> Connect -> RepositoryListener -> PromisesComponent -> Step4
As many as 4 intermediate classes, but each performs its function. An unnamed component takes the parameters passed to the function
connect()
, the class of the nested component, properties that are passed to the component itself when called (props) and passes them already to the component
Connect
. The component
Connect
is responsible for obtaining a set from the passed parameters
Promise
(a dictionary object with row keys and promise values).
PromisesComponent
provides the calculation of values, passing them back to the Connect component, which, together with the original transferred properties (props), calculated properties (values) and properties-actions (actions), passes them to the component
Step4
(via a call
React.createElement(...)
). Well, the component
RepositoryListener
updates the component, forcing to recalculate promis'y if something has changed in the repository.
As a result, if any of the components wants to use the repository from IndexDb, it will be enough for him to connect one function
connect()
, determine the mapping between the properties and the functions of obtaining properties from the repository, and use them without any additional headache.
Instead of a conclusion: what is left overboard
The above code is already practical enough to use in industrial solutions. But still, do not forget about the limitations:
- promise'. , . stamp IndexedDbRepository (, - ).
- , — . .
- , IndexedDB .
- : . . IndexDB «» , — .
- , IndexDB Redux Storage. IndexDB , .
Online-