Tutorial
This tutorial takes you through our Todo Example.
HomebaseProvider
Let's get started.
HomebaseProvider
is a component that wraps your React app and creates a local relational database. This database is then accessible to any child components via React Hooks.
1import React from 'react'
2import { HomebaseProvider, useTransact, useQuery, useEntity } from 'homebase-react'
3
4export const App = () => {
5 return (
6 <HomebaseProvider config={{ schema, initialData }}>
7 <Todos/>
8 </HomebaseProvider>
9 )
10}
Schema
Unlike other state managers, Homebase does not try to create yet another design pattern for state. Instead, we store state in a way we already know and love: as a relational graph database.
Like any good database we support schema on read.
At the moment schema is only for relationships and uniqueness constraints. It does not support typing of attributes, e.g. strings, integers, dates. We're working on adding the option to opt into schema on write support. This will provide basic type checking like you see in SQL.
1const schema = {
2 project: {
3 name: {
4 unique: 'identity'
5 }
6 },
7 todo: {
8 // refs are relationships
9 project: {
10 type: 'ref'
11 },
12 owner: {
13 type: 'ref'
14 }
15 }
16}
Initial Data
Hydrate your application with initial data. Here we add an initial user, project, and todo as well as the todoFilter that will filter the initial state to show todos that have been completed.
This data is a transaction that runs on database creation to seed your DB.
1const initialData = [
2 {
3 user: {
4 // negative numbers can be used as temporary ids in a transaction
5 id: -1,
6 name: 'Stella'
7 }
8 }, {
9 project: {
10 id: -3,
11 name: 'To the stars'
12 }
13 }, {
14 todo: {
15 name: 'Fix ship',
16 owner: -1,
17 project: -3,
18 isCompleted: true,
19 createdAt: new Date('2003/11/10')
20 }
21 }, {
22 todoFilter: {
23 // identity is a special attribute for user generated ids
24 // E.g. this is a setting that should be easy to lookup by name
25 identity: 'todoFilters',
26 showCompleted: true,
27 project: 0
28 }
29 }
30]
Config
And now we're ready to go. 馃殌
1const config = {
2 schema,
3 initialData
4}
Reading and Writing Data
Use the todoFilters we added earlier to filter the view. With Homebase everything is just data. It's similar to a reducer in React Hooks or Redux, but without the need to write bespoke mutation functions. We also introduce our useEntity
and useTransact
React Hooks here.
useEntity
enables you to grab the todoFilters Entity directly from Homebase by its identity
(a unique developer given name, like a custom id). Entities are the building blocks of the Homebase data model. They are like JSON objects with bonus features: you can traverse arbitrarily deep relationship without actually denormalizing and nesting your data.
useTransact
lets you transact
state from any component. Transactions let you create, update and delete multiple entities simultaneously and atomically. All changes will reactively update any components that depend on the changed data.
1const TodoFilters = () => {
2 const [filters] = useEntity({ identity: 'todoFilters' })
3 const [transact] = useTransact()
4 return (
5 <div>
6 <label htmlFor="show-completed">Show Completed?</label>
7 <input
8 type="checkbox"
9 id="show-completed"
10 checked={filters.get('showCompleted')}
11 onChange={e => transact([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked }}])}
12 />
13 路
14 <ProjectSelect
15 value={filters.get('project')}
16 onChange={project => transact([{ todoFilter: { id: filters.get('id'), project }}])}
17 />
18 </div>
19 )
20}
Entity and Transact Examples
In the following example todo
is a Homebase database entity being passed in as a prop.
As you probably noticed entities have a convenient function entity.get('attribute')
. It's like jsObj['attribute']
but with a lot of bonus benefits:
- You can chain attributes to traverse your relational graph
todo.get('project', 'owners', 0, 'name') => 'Stella'
- Chaining attributes that return undefined will return null instead of an error
jsObj.nullAttr.childAttr => Error
entity.get('nullAttr', 'childAttr') => null
- Caching is built in. Homebase tracks the attributes used by every component and only triggers re-renders when that specific data changes. This caching is scoped to our hooks, so while we're passing a
todo
entity in the following example it can be better to pass theid
as a prop and thenconst [todo] = useEntity(id)
in the component to create a new caching scope.
1const Todo = ({ todo }) => (
2 <div>
3 <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-end', paddingTop: 20}}>
4 <TodoCheck todo={todo} />
5 <TodoName todo={todo} />
6 </div>
7 <div>
8 <TodoProject todo={todo} />
9 路
10 <TodoOwner todo={todo} />
11 路
12 <TodoDelete todo={todo} />
13 </div>
14 <small style={{ color: 'grey' }}>
15 {todo.get('createdAt').toLocaleString()}
16 </small>
17 </div>
18)
Notice how id: todo.get('id')
is used in the following example to update an existing todo entity.
1const TodoCheck = ({ todo }) => {
2 const [transact] = useTransact()
3 return (
4 <input
5 type="checkbox"
6 style={{ width: 20, height: 20, cursor: 'pointer' }}
7 checked={!!todo.get('isCompleted')}
8 onChange={e => transact([{
9 todo: {
10 id: todo.get('id'),
11 isCompleted: e.target.checked
12 }
13 }])}
14 />
15 )
16}
17
useTransact
is incredibly convenient. It lets you create, update, and delete any state from any component.
1const TodoName = ({ todo }) => {
2 const [transact] = useTransact()
3 return (
4 <input
5 style={{
6 border: 'none', fontSize: 20, marginTop: -2, cursor: 'pointer',
7 ...todo.get('isCompleted') && { textDecoration: 'line-through '}
8 }}
9 value={todo.get('name')}
10 onChange={e => transact([{ todo: { id: todo.get('id'), name: e.target.value }}])}
11 />
12 )
13}
14
15const TodoProject = ({ todo }) => {
16 const [transact] = useTransact()
17 return (
18 <ProjectSelect
19 value={todo.get('project', 'id') || ''}
20 onChange={projectId => transact([{ todo: { id: todo.get('id'), 'project': projectId || null }}])}
21 />
22 )
23}
Queries
TodoOwner
introduces our first instance of the useQuery
React Hook. Query the relational database directly in your component using Javascript friendly syntax. Queries return an array of unique entities instead of an individual entity like useEntity
.
1const TodoOwner = ({ todo }) => {
2 const [transact] = useTransact()
3 const [users] = useQuery({
4 $find: 'user',
5 $where: { user: { name: '$any' } }
6 })
7...
Here's the full TodoOwner
component:
1const TodoOwner = ({ todo }) => {
2 const [transact] = useTransact()
3 const [users] = useQuery({
4 $find: 'user',
5 $where: { user: { name: '$any' } }
6 })
7 return (
8 <>
9 <label>
10 Owner:
11 </label>
12
13 <select
14 name="users"
15 value={todo.get('owner', 'id') || ''}
16 onChange={e => transact([{ todo: { id: todo.get('id'), owner: Number(e.target.value) || null }}])}
17 >
18 <option value=""></option>
19 {users.map(user => (
20 <option
21 key={user.get('id')}
22 value={user.get('id')}
23 >
24 {user.get('name')}
25 </option>
26 ))}
27 </select>
28 </>
29 ).
Our query API is powered by Datalog, and exposed as JSON similar to a JS SQL driver or MongoDB. Datalog is a subset of Prolog, a logic programming language, but with a few features restricted so it is guaranteed to terminate. This is not a perfect metaphor, but you can think of it as a more powerful version of SQL.
We don't go into Datalog queries here, instead easing you into our JSON query API for simplicity, but you can directly query Homebase
with Datalog as well. If you're interested in writing more sophisticated queries you can pass a datalog string instead of a JSON query as the first argument to useQuery.
E.g.
1const [users] = useQuery(`
2 [:find ?todo
3 :where [?todo :todo/owner ?user]
4 [?user :user/name "Stella"]]
5`)
This will return all todos owned by users named Stella. As you can see joins are implicit by using the ?user
variable in multiple :where
clauses.
We won't get into more detail about Datalog here since it's essentially a programming language. The syntax we use has it's roots in Clojure and Datomic. If you'd like us to priortize documention for these advanced queries please let us know. In the meantime we recommend Learn Datalog Today as a good place to start.
Create Data
It's all just transactions. Yes it's repetitive, but the goal of Homebase it to make data declarative and composable on the client and the server. This means providing a powerful core library and letting you combine the pieces to declare what you want, without needing to say how to do achieve it.
1const NewTodo = () => {
2 const [transact] = useTransact()
3 return (
4 <form onSubmit={e => {
5 e.preventDefault()
6 transact([{
7 todo: {
8 name: e.target.elements['todo-name'].value,
9 createdAt: new Date
10 }
11 }])
12 e.target.reset()
13 }}>
14 <input
15 autoFocus
16 type="text"
17 name="todo-name"
18 placeholder="What needs to be done?"
19 autoComplete="off"
20 required
21 />
22
23 <button type="submit">Create Todo</button>
24 </form>
25 )
26}
Delete Data
The object style transactions support deletion of individual attributes by setting them to null.
1transact({ todo: { id: 123, name: null } })
To delete entire entities we provide a database function retractEntity
. Database functions are atomic functions that run inside of transactions, basically they're reducers. Database functions are invoked by passing an array with the function name first followed by arguments ['retractEntity', 123]
.
1const TodoDelete = ({ todo }) => {
2 const [transact] = useTransact()
3 return (
4 <button onClick={() => transact([['retractEntity', todo.get('id')]])}>
5 Delete
6 </button>
7 )
8}
The Full Example
Here's everything we covered. You can try the app for yourself here.
If you're interested in integrating a backend you can check out our Firebase example here for inspiration or ping us at hi@homebase.io or in our message board to pair on integrating a custom backend.
1import React from 'react'
2import { HomebaseProvider, useTransact, useQuery, useEntity } from 'homebase-react'
3
4export const App = () => {
5 return (
6 <HomebaseProvider config={config}>
7 <Todos />
8 </HomebaseProvider>
9 )
10}
11
12const config = {
13 // Schema is only used to enforce
14 // unique constraints and relationships.
15 // It is not a type system, yet.
16 schema: {
17 project: { name: { unique: 'identity' } },
18 todo: {
19 // refs are relationships
20 project: { type: 'ref' },
21 owner: { type: 'ref' }
22 }
23 },
24 // Initial data let's you conveniently transact some
25 // starting data on DB creation to hydrate your components.
26 initialData: [
27 {
28 user: {
29 // negative numbers can be used as temporary ids in a transaction
30 id: -1,
31 name: 'Stella'
32 }
33 }, {
34 project: {
35 id: -3,
36 name: 'To the stars'
37 }
38 }, {
39 todo: {
40 name: 'Fix ship',
41 owner: -1,
42 project: -3,
43 isCompleted: true,
44 createdAt: new Date('2003/11/10')
45 }
46 }, {
47 todoFilter: {
48 // identity is a special attribute for user generated ids
49 // E.g. this is a setting that should be easy to lookup by name
50 identity: 'todoFilters',
51 showCompleted: true,
52 project: 0
53 }
54 }
55 ]
56}
57
58const Todos = () => {
59 return (
60 <div>
61 <NewTodo />
62 <TodoFilters />
63 <TodoList />
64 </div>
65 )
66}
67
68const NewTodo = () => {
69 const [transact] = useTransact()
70 return (
71 <form onSubmit={e => {
72 e.preventDefault()
73 transact([{
74 todo: {
75 name: e.target.elements['todo-name'].value,
76 createdAt: new Date()
77 }
78 }])
79 e.target.reset()
80 }}>
81 <input
82 autoFocus
83 type="text"
84 name="todo-name"
85 placeholder="What needs to be done?"
86 autoComplete="off"
87 required
88 />
89
90 <button type="submit">Create Todo</button>
91 </form>
92 )
93}
94
95const TodoList = () => {
96 const [filters] = useEntity({ identity: 'todoFilters' })
97 const [todos] = useQuery({
98 $find: 'todo',
99 $where: { todo: { name: '$any' } }
100 })
101 return (
102 <div>
103 {todos.filter(todo => {
104 if (!filters.get('showCompleted') && todo.get('isCompleted')) return false
105 if (filters.get('project') && todo.get('project', 'id') !== filters.get('project')) return false
106 if (filters.get('owner') && todo.get('owner', 'id') !== filters.get('owner')) return false
107 return true
108 }).sort((a, b) => a.get('createdAt') > b.get('createdAt') ? -1 : 1)
109 .map(todo => <Todo key={todo.get('id')} id={todo.get('id')}/>)}
110 </div>
111 )
112}
113
114// PERFORMANCE: By accepting an `id` prop instead of a whole `todo` entity
115// this component stays disconnected from the useQuery in the parent TodoList.
116// useEntity creates a separate scope for every Todo so changes to TodoList
117// or sibling Todos don't trigger unnecessary re-renders.
118const Todo = React.memo(({ id }) => {
119 const [todo] = useEntity(id)
120 return (
121 <div>
122 <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-end', paddingTop: 20}}>
123 <TodoCheck todo={todo} />
124 <TodoName todo={todo} />
125 </div>
126 <div>
127 <TodoProject todo={todo} />
128 路
129 <TodoOwner todo={todo} />
130 路
131 <TodoDelete todo={todo} />
132 </div>
133 <small style={{ color: 'grey' }}>
134 {todo.get('createdAt').toLocaleString()}
135 </small>
136 </div>
137 )
138})
139
140const TodoCheck = ({ todo }) => {
141 const [transact] = useTransact()
142 return (
143 <input
144 type="checkbox"
145 style={{ width: 20, height: 20, cursor: 'pointer' }}
146 checked={!!todo.get('isCompleted')}
147 onChange={e => transact([{ todo: { id: todo.get('id'), isCompleted: e.target.checked } }])}
148 />
149 )
150}
151
152const TodoName = ({ todo }) => {
153 const [transact] = useTransact()
154 return (
155 <input
156 style={{
157 border: 'none', fontSize: 20, marginTop: -2, cursor: 'pointer',
158 ...todo.get('isCompleted') && { textDecoration: 'line-through '}
159 }}
160 defaultValue={todo.get('name')}
161 onChange={e => transact([{ todo: { id: todo.get('id'), name: e.target.value }}])}
162 />
163 )
164}
165
166const TodoProject = ({ todo }) => {
167 const [transact] = useTransact()
168 return (
169 <EntitySelect
170 label="Project"
171 entityType="project"
172 value={todo.get('project', 'id')}
173 onChange={project => transact([{ todo: { id: todo.get('id'), project }}])}
174 />
175 )
176}
177
178const TodoOwner = ({ todo }) => {
179 const [transact] = useTransact()
180 return (
181 <EntitySelect
182 label="Owner"
183 entityType="user"
184 value={todo.get('owner', 'id')}
185 onChange={owner => transact([{ todo: { id: todo.get('id'), owner }}])}
186 />
187 )
188}
189
190const TodoDelete = ({ todo }) => {
191 const [transact] = useTransact()
192 return (
193 <button onClick={() => transact([['retractEntity', todo.get('id')]])}>
194 Delete
195 </button>
196 )
197}
198
199const TodoFilters = () => {
200 const [filters] = useEntity({ identity: 'todoFilters' })
201 const [transact] = useTransact()
202 return (
203 <div>
204 <label>Show Completed?
205 <input
206 type="checkbox"
207 checked={filters.get('showCompleted')}
208 onChange={e => transact([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked }}])}
209 />
210 </label>
211 路
212 <EntitySelect
213 label="Project"
214 entityType="project"
215 value={filters.get('project')}
216 onChange={project => transact([{ todoFilter: { id: filters.get('id'), project }}])}
217 />
218 路
219 <EntitySelect
220 label="Owner"
221 entityType="user"
222 value={filters.get('owner')}
223 onChange={owner => transact([{ todoFilter: { id: filters.get('id'), owner }}])}
224 />
225 </div>
226 )
227}
228
229const EntitySelect = React.memo(({ label, entityType, value, onChange }) => {
230 const [entities] = useQuery({
231 $find: entityType,
232 $where: { [entityType]: { name: '$any' } }
233 })
234 return (
235 <label>{label}:
236 <select
237 name={entityType}
238 value={value || ''}
239 onChange={e => onChange && onChange(Number(e.target.value) || null)}
240 >
241 <option key="-" value=""></option>
242 {entities.map(entity => (
243 <option key={entity.get('id')} value={entity.get('id')}>
244 {entity.get('name')}
245 </option>
246 ))}
247 </select>
248 </label>
249 )
250})
Thanks!
Thanks for trying us out! We're excited to see what you build and would love to hear any feedback.