Suggest an edit

Tutorial

This tutorial takes you through our Todo Example.

Custom chrome formatters

We recommend everyone start by enabling custom chrome formatters for a much better debugging experience.

image of custom entity chrome console logs

HomebaseProvider

Welcome back. Let's get started for real.

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={{ lookupHelpers, initialData }}>
7      <Todos/>
8    </HomebaseProvider>
9  )
10}

Lookup Helpers

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.

Lookup helpers make relationships and uniqueness constraints something you declare once and then never need to worry about again. Subsequent queries and transactions will take these properties into account so you never have to write a join query.

1const lookupHelpers = {
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	lookupHelpers,
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      &nbsp;路&nbsp;
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:

  1. You can chain attributes to traverse your relational graph
    • todo.get('project', 'owners', 0, 'name') => 'Stella'
  2. Chaining attributes that return undefined will return null instead of an error
    • jsObj.nullAttr.childAttr => Error
    • entity.get('nullAttr', 'childAttr') => null
  3. 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 the id as a prop and then const [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      &nbsp;路&nbsp;
10      <TodoOwner todo={todo} />
11      &nbsp;路&nbsp;
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      &nbsp;
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      &nbsp;
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  // Lookup helpers are used to enforce 
14  // unique constraints and relationships.
15  lookupHelpers: {
16    project: { name: { unique: 'identity' } },
17    todo: {
18      // refs are relationships
19      project: { type: 'ref' },
20      owner: { type: 'ref' }
21    }
22  },
23  // Initial data let's you conveniently transact some 
24  // starting data on DB creation to hydrate your components.
25  initialData: [
26    {
27      user: {
28        // negative numbers can be used as temporary ids in a transaction
29        id: -1, 
30        name: 'Stella' 
31      }
32    }, {
33      project: {
34        id: -3,
35        name: 'To the stars'
36      }
37    }, {
38      todo: {
39        name: 'Fix ship',
40        owner: -1,
41        project: -3,
42        isCompleted: true,
43        createdAt: new Date('2003/11/10')
44      }
45    }, {
46      todoFilter: {
47        // identity is a special attribute for user generated ids
48        // E.g. this is a setting that should be easy to lookup by name
49        identity: 'todoFilters',
50        showCompleted: true,
51        project: 0
52      }
53    }
54  ]
55}
56
57const Todos = () => {
58  return (
59    <div>
60      <NewTodo />
61      <TodoFilters />
62      <TodoList />
63    </div>
64  )
65}
66
67const NewTodo = () => {
68  const [transact] = useTransact()
69  return (
70    <form onSubmit={e => {
71      e.preventDefault()
72      transact([{
73        todo: {
74          name: e.target.elements['todo-name'].value,
75          createdAt: new Date()
76        }
77      }])
78      e.target.reset()
79    }}>
80      <input 
81        autoFocus
82        type="text" 
83        name="todo-name" 
84        placeholder="What needs to be done?" 
85        autoComplete="off"
86        required
87      />
88      &nbsp;
89      <button type="submit">Create Todo</button>
90    </form>
91  )
92}
93
94const TodoList = () => {
95  const [filters] = useEntity({ identity: 'todoFilters' })
96  const [todos] = useQuery({
97    $find: 'todo',
98    $where: { todo: { name: '$any' } }
99  })
100  return (
101    <div>
102      {todos.filter(todo => {
103        if (!filters.get('showCompleted') && todo.get('isCompleted')) return false
104        if (filters.get('project') && todo.get('project', 'id') !== filters.get('project')) return false
105        if (filters.get('owner') && todo.get('owner', 'id') !== filters.get('owner')) return false
106        return true
107      }).sort((a, b) => a.get('createdAt') > b.get('createdAt') ? -1 : 1)
108      .map(todo => <Todo key={todo.get('id')} id={todo.get('id')}/>)}
109    </div>
110  )
111}
112
113// PERFORMANCE: By accepting an `id` prop instead of a whole `todo` entity
114// this component stays disconnected from the useQuery in the parent TodoList. 
115// useEntity creates a separate scope for every Todo so changes to TodoList
116// or sibling Todos don't trigger unnecessary re-renders.
117const Todo = React.memo(({ id }) => {
118  const [todo] = useEntity(id)
119  return (
120    <div>
121      <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-end', paddingTop: 20}}>
122        <TodoCheck todo={todo} />
123        <TodoName todo={todo} />
124      </div>
125      <div>
126        <TodoProject todo={todo} />
127        &nbsp;路&nbsp;
128        <TodoOwner todo={todo} />
129        &nbsp;路&nbsp;
130        <TodoDelete todo={todo} />
131      </div>
132      <small style={{ color: 'grey' }}>
133        {todo.get('createdAt').toLocaleString()}
134      </small>
135    </div>
136  )
137})
138
139const TodoCheck = ({ todo }) => {
140  const [transact] = useTransact()
141  return (
142    <input 
143      type="checkbox"
144      style={{ width: 20, height: 20, cursor: 'pointer' }}
145      checked={!!todo.get('isCompleted')}
146      onChange={e => transact([{ todo: { id: todo.get('id'), isCompleted: e.target.checked } }])}
147    />
148  )
149}
150
151const TodoName = ({ todo }) => {
152  const [transact] = useTransact()
153  return (
154    <input 
155      style={{  
156        border: 'none', fontSize: 20, marginTop: -2, cursor: 'pointer',
157        ...todo.get('isCompleted') && { textDecoration: 'line-through '}
158      }}
159      defaultValue={todo.get('name')}
160      onChange={e => transact([{ todo: { id: todo.get('id'), name: e.target.value }}])}
161    />
162  )
163}
164
165const TodoProject = ({ todo }) => {
166  const [transact] = useTransact()
167  return (
168    <EntitySelect
169      label="Project"
170      entityType="project"
171      value={todo.get('project', 'id')}
172      onChange={project => transact([{ todo: { id: todo.get('id'), project }}])}
173    />    
174  )
175}
176
177const TodoOwner = ({ todo }) => {
178  const [transact] = useTransact()
179  return (
180    <EntitySelect 
181      label="Owner"
182      entityType="user" 
183      value={todo.get('owner', 'id')}
184      onChange={owner => transact([{ todo: { id: todo.get('id'), owner }}])}
185    />
186  )
187}
188
189const TodoDelete = ({ todo }) => {
190  const [transact] = useTransact()
191  return (
192    <button onClick={() => transact([['retractEntity', todo.get('id')]])}>
193      Delete
194    </button>
195  )
196}
197
198const TodoFilters = () => {
199  const [filters] = useEntity({ identity: 'todoFilters' })
200  const [transact] = useTransact()
201  return (
202    <div>
203      <label>Show Completed?
204        <input 
205          type="checkbox" 
206          checked={filters.get('showCompleted')}
207          onChange={e => transact([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked }}])}
208        />
209      </label>
210      &nbsp;路&nbsp;
211      <EntitySelect
212        label="Project"
213        entityType="project"
214        value={filters.get('project')}
215        onChange={project => transact([{ todoFilter: { id: filters.get('id'), project }}])}
216      />
217      &nbsp;路&nbsp;
218      <EntitySelect
219        label="Owner"
220        entityType="user"
221        value={filters.get('owner')}
222        onChange={owner => transact([{ todoFilter: { id: filters.get('id'), owner }}])}
223      />
224    </div>
225  )
226}
227
228const EntitySelect = React.memo(({ label, entityType, value, onChange }) => {
229  const [entities] = useQuery({
230    $find: entityType,
231    $where: { [entityType]: { name: '$any' } }
232  })
233  return (
234    <label>{label}:&nbsp;
235      <select 
236        name={entityType} 
237        value={value || ''}
238        onChange={e => onChange && onChange(Number(e.target.value) || null)}
239      >
240        <option key="-" value=""></option>
241        {entities.map(entity => (
242          <option key={entity.get('id')} value={entity.get('id')}>
243            {entity.get('name')}
244          </option>
245        ))}
246      </select>
247    </label>
248  )
249})

Thanks!

Thanks for trying us out! We're excited to see what you build and would love to hear any feedback.