React + IndexDb + auto-update = almost AsyncRedux

In this article, I will tell you step by step how to prepare IndexDB (a database that is built into any modern browser) for use in projects written in ReactJS. As a result, you can use the data from IndexDB as conveniently as if it were in the Redux Store of your application.



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:





Three key differences between IndexDB and Redux Store are important to us:



  1. 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)
  2. IndexDB is a completely asynchronous DBMS. All operations - opening, reading, writing, searching - are asynchronous.
  3. 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:





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:





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
 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:





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:





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:





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 componentRepositoryListener



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:









Online-



All Articles