Suggest an edit

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      &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  // 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      &nbsp;
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        &nbsp;路&nbsp;
129        <TodoOwner todo={todo} />
130        &nbsp;路&nbsp;
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      &nbsp;路&nbsp;
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      &nbsp;路&nbsp;
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}:&nbsp;
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.