react-use-database
relational data at its simplest
react-use-database gives you an opinionated interface, efficient data flow, and concise global state management. It forces you to think about your client-side data in the context of a queryable database. It gives you two global data stores: an entity store and a query store.
The entity store contains all of your model data. It’s just a big json blob that is the source of truth for any database entity that you have defined via Normalizr’s notion of schemas (eg UserSchema, TodoSchema, CommentSchema, etc.).
Note, Normalizr is a peer dependency of react-use-database, you should familiarize yourself with it.
But, what good is a database without a way to pull data from it? That’s where the query store comes in. A query is comprised of a schema and a value. The query {schema: UserSchema, value: 32}
would return you a user whose id is 32. The query {schema: [UserSchema], value: [32, 33, 34]}
would return you an array of three users. By tracking and updating your entities and queries in their respective stores, every component can have access to the latest data with almost no additional code.
I used to implement relational state management with Redux/Normalizr. It worked well, but there was too much boilerplate and trying to onboard a new-hire to the idea was daunting. Redux and Normalizr are an incredible pairing, Dan Abramov was really onto something with his creation of each (He’s even done an Egghead tutorial series in which he describes almost the exact design pattern I’ve implemented in this library). The problem with Redux is that it lacks opinionation and it can be overwhelmingly verbose. It’s vulnerable to anti-patterns and there’s nothing inherent to its API to enforce its proper use. When used improperly, redux can become more of a burden than an asset.
Install
npm install --save react-use-database
Demo
Live Demo: https://malerba118.github.io/react-use-database/
Demo Code: https://github.com/malerba118/react-use-database/tree/master/example/src
Simplest Usage
import React from "react";
import ReactDOM from "react-dom";
import { schema } from "normalizr";
import createDB from "react-use-database";
const TodoSchema = new schema.Entity("Todo");
let [DatabaseProvider, useDB] = createDB([TodoSchema], {
defaultEntities: {
Todo: {
1: {
id: 1,
text: "Buy cheese"
}
}
}
});
const queries = {
getTodoById: id => {
return {
schema: TodoSchema,
value: id
};
}
};
const App = props => {
let db = useDB();
let queryToGetTodoWithIdOne = queries.getTodoById(1);
let todo = db.executeQuery(queryToGetTodoWithIdOne);
return <span>{todo.text}</span>;
};
ReactDOM.render(
<DatabaseProvider>
<App />
</DatabaseProvider>,
document.getElementById("root")
);
Complex Usage
Creating the Database
See the code
Once we wrap our app in the DatabaseProvider we’re good to go. We can now use our database hook in any component to query the database.
Fetching Todos
See the code
Boom, now you can fetchTodos from anywhere and your TodosComponent will re-render with the latest list of todos.
Updating a Todo
See the code
This has to be my favorite one. You don’t even need to update any queries. You just take the updated Todo from the response body, normalize it, and deep merge it into the entity store and then your TodosComponent re-renders with the updated data. Virtually zero effort involved.
Creating a Todo
See the code
You might be seeing a pattern here. Updating the database is almost always as simple as normalizing data and passing the entities object to mergeEntities. This deep merges the entities patch onto the existing entities object. Once our Todo is created, we need to add its id to our ALL_TODOS query. Once this is done, our TodosComponent will re-render with the new todo.
Deleting a Todo
See the code
To delete a todo, we can add a soft delete indicator to the todo in the database and we can update any relevant queries to omit the deleted todo id. Here’s a case where we probably don’t want to just normalize the data from the response and call mergeEntities on it.
Optimistic Updates
See the code
We also can easily perform optimistic updates by normalizing and merging entities before the API call has finished. And then if the API call comes back with an error level status code, we can merge the original todo back into entities to revert the update.
Other Queries
See the code
Because a query is just a schema and value, we can create our own queries whose state is not tracked by the query store. For example, if we received a todo id as a url parameter, we could do something like the above.
API
createDB(entitySchemas, options)
Creates DatabaseProvider and useDB hook.
entitySchemas
: required Array or object whose values are normalizr Entity schemasoptions
: optional options
storedQueryDefinitions
: Object whose keys are query names and whose values have form { schema, defaultValue }
defaultEntities
: An entities object to seed the database
Usage
let [ DatabaseProvider, useDB ] = createDB(
models,
{
storedQueryDefinitions: {
ALL_TODOS: {
schema: [models.TodoSchema],
defaultValue: []
},
ACTIVE_TODOS: {
schema: [models.TodoSchema],
defaultValue: []
},
COMPLETED_TODOS: {
schema: [models.TodoSchema],
defaultValue: []
},
},
defaultEntities: {
Todo: {
1: {
id: 1,
text: 'Buy cheese',
completed: false
}
}
}
}
);
DatabaseProvider
React context provider that enables react-use-database to have global state.
Usage
ReactDOM.render(
<DatabaseProvider>
<App />
</DatabaseProvider>,
document.getElementById("root")
);
useDB()
React database hook that allows you to query and update the database
Usage
const useNormalizedApi = () => {
let db = useDB();
return {
...
addTodo: async (text) => {
let todo = await api.addTodo(text);
let { result, entities } = normalize(
todo,
apiSchemas.addTodoResponseSchema
);
db.mergeEntities(entities);
db.updateStoredQuery('ALL_TODOS', (prevArray) => [...prevArray, todo.id]);
},
...
};
};
const TodosComponent = (props) => {
let db = useDB();
let allTodosQuery = db.getStoredQuery('ALL_TODOS');
let todos = db.executeQuery(allTodosQuery);
return (
<JSON data={todos} />
)
}
mergeEntities(entitiesPatch, options)
Immutably deep merges an entities patch on to the current entities object to produce next entities state.
entitiesPatch
: required function or partial entities object. If function, one argument will be passed, the current entities. Under the hood, lodash's mergeWith is called to merge the entitiesPatch onto the current entities to produce the next enitities object.options
: optional options
Usage
const TodosComponent = (props) => {
let db = useDB();
let todo = db.executeQuery({schema: TodoSchema, value: 1});
useEffect(() => {
db.mergeEntities({
Todo: {
1: {
id: 1,
text: 'Buy cheese',
completed: false
}
}
})
}, [])
return (
<JSON data={todo} />
)
}
executeQuery(query)
Executes a query against the database (db.entities).
query
: required object with shape {schema: normalizr.schema, value: any}
Usage
const TodosComponent = (props) => {
let db = useDB();
let todos = db.executeQuery({schema: [TodoSchema], value: [1, 2, 3]});
return (
<JSON data={todos} />
)
}
getStoredQuery(storedQueryName)
Gets the current query state for the query name provided.
storedQueryName
: required query name from keys defined in storedQueryDefinitions
Usage
const models = [TodoSchema]
let [ DatabaseProvider, useDB ] = createDB(
models,
{
storedQueryDefinitions: {
ALL_TODOS: {
schema: [TodoSchema],
defaultValue: [1, 2, 3]
}
}
}
);
const TodosComponent = (props) => {
let db = useDB();
let allTodosQuery = db.getStoredQuery('ALL_TODOS');
console.log(allTodosQuery)
let todos = db.executeQuery(allTodosQuery);
return (
<JSON data={todos} />
)
}
executeStoredQuery(storedQueryName)
An alias for db.executeQuery(db.getStoredQuery(storedQueryName))
.
storedQueryName
: required query name from keys defined in storedQueryDefinitions
Usage
const TodosComponent = (props) => {
let db = useDB();
let todos = db.executeStoredQuery('ALL_TODOS');
return (
<JSON data={todos} />
)
}
entities
The root data object from the entity store. Useful to listen to state changes.
Could be used to persist parts of state to local storage or to implement undo/redo features.
Entities saved to local storage could be used to hydrate the store via createDB's defaultEntities option.
Usage
let [ DatabaseProvider, useDB ] = createDB(
models,
{
defaultEntities: LocalStorageClient.loadState('entities')
}
);
const useEntityListener = () => {
let db = useDB();
useEffect(() => {
LocalStorageClient.saveState('entities', db.entities)
}, [db.entities])
}
storedQueries
The root data object from the query store. Useful to listen to state changes.
Could be used to persist parts of state to local storage or to implement undo/redo features.
Usage
const useStoredQueryListener = () => {
let db = useDB();
useEffect(() => {
}, [db.storedQueries])
}
License
MIT © malerba118