8000 feat(homebase.react): add useClient hook (#19) · homebaseio/homebase-react@5f5b3fc · GitHub
[go: up one dir, main page]

Skip to content

Commit 5f5b3fc

Browse files
authored
feat(homebase.react): add useClient hook (#19)
- client fn to serialize db to string - client fn to deserialize db from string - client fn to create transaction listener - client fn to transact and bypass listeners - example of useClient syncing data with firebase - fix caching for hooks that do not unmount when their entities are retracted
1 parent 8ee2534 commit 5f5b3fc

File tree

11 files changed

+1472
-30
lines changed

11 files changed

+1472
-30
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,24 @@ todos
149149
.map(todo => todo.get('name'))
150150
```
151151

152+
### `useClient`
153+
154+
This hook returns the current database client with some helpful functions for syncing data with a backend.
155+
156+
- `client.dbToString()` serializes the whole db including the schema to a string
157+
- `client.dbFromString('a serialized db string')` replaces the current db
158+
- `client.dbToDatoms()` returns an array of all the facts aka datoms saved in the db
159+
- datoms are the smallest unit of data in the database, like a key value pair but better
160+
- they are arrays of `[entityId, attribute, value, transactionId, isAddedBoolean]`
161+
- `client.addTransactListener((changedDatoms) => ...)` adds a listener function to all transactions
162+
- use this to save data to your backend
163+
- `client.removeTransactionListener()` removes the transaction listener
164+
- please note that only 1 listener can be added per useClient scope
165+
- `client.transactSilently([{item: {name: ...}}])` like `transact()` only it will not trigger any listeners
166+
- use this to sync data from your backend into the client
167+
168+
Check out the [Firebase example](https://homebaseio.github.io/homebase-react/#!/example.todo_firebase) for a demonstration of how you might integrate a backend.
169+
152170
## Performance
153171

154172
Homebase React tracks the attributes consumed in each component via the `entity.get` function and scopes those attributes to their respective `useEntity` or `useQuery` hook. Re-renders are only triggered when an attribute changes.

js/todo-firebase-example.jsx

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
import React from 'react'
2+
import firebase from 'firebase/app'
3+
import 'firebase/auth'
4+
import 'firebase/database'
5+
import firebaseui from 'firebaseui'
6+
const { HomebaseProvider, useClient, useTransact, useQuery, useEntity } = window.homebase.react
7+
8+
export const App = () => {
9+
return (
10+
<HomebaseProvider config={config}>
11+
<AuthPrompt>
12+
<Todos />
13+
</AuthPrompt>
14+
</HomebaseProvider>
15+
)
16+
}
17+
18+
const config = {
19+
// Schema is only used to enforce
20+
// unique constraints and relationships.
21+
// It is not a type system, yet.
22+
schema: {
23+
user: { uid: { unique: 'identity' } },
24+
todo: {
25+
// refs are relationships
26+
project: { type: 'ref' },
27+
owner: { type: 'ref' }
28+
}
29+
},
30+
// Initial data let's you conveniently transact some
31+
// starting data on DB creation to hydrate your components.
32+
initialData: [
33+
{
34+
todoFilter: {
35+
// identity is a special unique attribute for user generated ids
36+
// E.g. todoFilters are settings that should be easy to lookup by their identity
37+
identity: 'todoFilters',
38+
showCompleted: true
39+
}
40+
}, {
41+
user: {
42+
// Negative numbers can be used as temporary ids in a transaction.
43+
// Use them to relate multiple entities together at once.
44+
id: -1,
45+
name: 'Stella'
46+
}
47+
}, {
48+
user: {
49+
id: -2,
50+
name: 'Arpegius'
51+
}
52+
}, {
53+
project: {
54+
id: -3,
55+
name: 'Make it'
56+
}
57+
}, {
58+
project: {
59+
id: -4,
60+
name: 'Do it'
61+
}
62+
}
63+
]
64+
}
65+
66+
const firebaseConfig = {
67+
apiKey: "AIzaSyC31X8R5-doWtVmbBRD0xCue09HfydfjzI",
68+
authDomain: "homebase-react.firebaseapp.com",
69+
databaseURL: "https://homebase-react.firebaseio.com",
70+
projectId: "homebase-react",
71+
storageBucket: "homebase-react.appspot.com",
72+
messagingSenderId: "1056367825432",
73+
appId: "1:1056367825432:web:a6aaba7bee5e8a43e6296d",
74+
measurementId: "G-FJ9BNZDFCE"
75+
}
76+
77+
firebase.initializeApp(firebaseConfig)
78+
const firebaseUI = new firebaseui.auth.AuthUI(firebase.auth())
79+
80+
const AuthPrompt = ({ children }) => {
81+
const [transact] = useTransact()
82+
const [currentUser] = useEntity({ identity: 'currentUser' })
83+
const [client] = useClient()
84+
React.useEffect(() => {
85+
window.emptyDB = client.dbToString()
86+
return firebase.auth().onAuthStateChanged((user) => {
87+
if (user) {
88+
transact([{ user: { uid: user.uid, name: user.displayName } }])
89+
client.transactSilently([{ currentUser: { identity: 'currentUser', uid: user.uid }}])
90+
}
91+
})
92+
}, [])
93+
if (currentUser.get('uid')) return children
94+
return <SignIn />
95+
}
96+
97+
const SignIn = () => {
98+
React.useEffect(() => {
99+
firebaseUI.start('#firebaseui-auth-container', {
100+
signInFlow: 'popup',
101+
signInSuccessUrl: window.location.href,
102+
signInOptions: [
103+
firebase.auth.EmailAuthProvider.PROVIDER_ID,
104+
firebase.auth.GoogleAuthProvider.PROVIDER_ID,
105+
],
106+
callbacks: {
107+
signInSuccessWithAuthResult: () => false
108+
},
109+
})
110+
}, [])
111+
return <div id="firebaseui-auth-container" />
112+
}
113+
114+
const Todos = () => (
115+
<div>
116+
<DataSaver />
117+
<SignOut />
118+
<NewTodo />
119+
<hr/>
120+
<TodoFilters />
121+
<TodoList />
122+
</div>
123+
)
124+
125+
const DataSaver = () => {
126+
const [client] = useClient()
127+
const [currentUser] = useEntity({ identity: 'currentUser' })
128+
const userId = currentUser.get('uid')
129+
const transactListener = React.useCallback((changedDatoms) => {
130+
const numDatomChanges = changedDatoms.reduce((acc, [id, attr]) => (
131+
{...acc, [id + attr]: (acc[id + attr] || 0) + 1}
132+
), {})
133+
const datomsForFirebase = changedDatoms.filter(([id, attr, _, __, isAdded]) => !(!isAdded && numDatomChanges[id + attr] > 1))
134+
datomsForFirebase.forEach(([id, attr, v, tx, isAdded]) => {
135+
const ref = firebase.database().ref(`users/${userId}/entities/${id}|${attr.replace('/', '|')}`)
136+
isAdded ? ref.set([id, attr, v, tx, isAdded]) : ref.remove()
137+
})
138+
}, [userId])
139+
React.useEffect(() => {
140+
client.addTransactListener(transactListener)
141+
const ref = firebase.database().ref(`users/${userId}/entities`)
142+
const on = (action) => (ds) => client.transactSilently([[action, ...ds.val()]])
143+
ref.on('child_added', on('add'))
144+
ref.on('child_removed', on('retract'))
145+
ref.on('child_changed', on('add'))
146+
return () => {
147+
client.removeTransactListener()
148+
ref.off('child_added', on('add'))
149+
ref.off('child_removed', on('retract'))
150+
ref.off('child_changed', on('add'))
151+
}
152+
}, [userId])
153+
return null
154+
}
155+
156+
const SignOut = () => {
157+
const [client] = useClient()
158+
return (
159+
<button
160+
style={{float: 'right'}}
161+
onClick={() => {
162+
client.dbFromString(window.emptyDB)
163+
firebase.auth().signOut()
164+
}}
165+
>Sign Out</button>
166+
)
167+
}
168+
169+
const NewTodo = () => {
170+
const [transact] = useTransact()
171+
return (
172+
<form onSubmit={e => {
173+
e.preventDefault()
174+
transact([{
175+
todo: {
176+
name: e.target.elements['todo-name'].value,
177+
createdAt: Date.now()
178+
}
179+
}])
180+
e.target.reset()
181+
}}>
182+
<input
183+
autoFocus
184+
style={{fontSize: 20}}
185+
type="text"
186+
name="todo-name"
187+
placeholder="What needs to be done?"
188+
autoComplete="off"
189+
required
190+
/>
191+
&nbsp;
192+
<button type="submit">Create Todo</button>
193+
</form>
194+
)
195+
}
196+
197+
const TodoList = () => {
198+
const [filters] = useEntity({ identity: 'todoFilters' })
199+
const [todos] = useQuery({
200+
$find: 'todo',
201+
$where: { todo: { name: '$any' } }
202+
})
203+
return (
204+
<div>
205+
{todos.filter(todo => {
206+
if (!filters.get('showCompleted') && todo.get('isCompleted')) return false
207+
if (filters.get('project') && todo.get('project', 'id') !== filters.get('project')) return false
208+
if (filters.get('owner') && todo.get('owner', 'id') !== filters.get('owner')) return false
209+
return true
210+
}).sort((a, b) => a.get('createdAt') > b.get('createdAt') ? -1 : 1)
211+
.map(todo => <Todo key={todo.get('id')} id={todo.get('id')}/>)}
212+
</div>
213+
)
214+
}
215+
216+
// PERFORMANCE: By accepting an `id` prop instead of a whole `todo` entity
217+
// this component stays disconnected from the useQuery in the parent TodoList.
218+
// useEntity creates a separate scope for every Todo so changes to TodoList
219+
// or sibling Todos don't trigger unnecessary re-renders.
220+
const Todo = React.memo(({ id }) => {
221+
const [todo] = useEntity(id)
222+
return (
223+
<div>
224+
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-end', paddingTop: 20}}>
225+
<TodoCheck todo={todo} />
226+
<TodoName todo={todo} />
227+
</div>
228+
<div>
229+
<TodoProject todo={todo} />
230+
&nbsp;·&nbsp;
231+
<TodoOwner todo={todo} />
232+
&nbsp;·&nbsp;
233+
<TodoDelete todo={todo} />
234+
</div>
235+
<small style={{ color: 'grey' }}>
236+
{new Date(todo.get('createdAt')).toLocaleString()}
237+
</small>
238+
</div>
239+
)
240+
})
241+
242+
const TodoCheck = ({ todo }) => {
243+
const [transact] = useTransact()
244+
return (
245+
<input
246+
type="checkbox"
247+
style={{ width: 20, height: 20, cursor: 'pointer' }}
248+
checked={!!todo.get('isCompleted')}
249+
onChange={e => transact([{ todo: { id: todo.get('id'), isCompleted: e.target.checked } }])}
250+
/>
251+
)
252+
}
253+
254+
const TodoName = ({ todo }) => {
255+
const [transact] = useTransact()
256+
return (
257+
<input
258+
style={{
259+
border: 'none', fontSize: 20, marginTop: -2, cursor: 'pointer',
260+
...todo.get('isCompleted') && { textDecoration: 'line-through '}
261+
}}
262+
value={todo.get('name') || ''}
263+
onChange={e => transact([{ todo: { id: todo.get('id'), name: e.target.value }}])}
264+
/>
265+
)
266+
}
267+
268+
const TodoProject = ({ todo }) => {
269+
const [transact] = useTransact()
270+
return (
271+
<EntitySelect
272+
label="Project"
273+
entityType="project"
274+
value={todo.get('project', 'id')}
275+
onChange={project => transact([{ todo: { id: todo.get('id'), project }}])}
276+
/>
277+
)
278+
}
279+
280+
const TodoOwner = ({ todo }) => {
281+
const [transact] = useTransact()
282+
return (
283+
<EntitySelect
284+
label="Owner"
285+
entityType="user"
286+
value={todo.get('owner', 'id')}
287+
onChange={owner => transact([{ todo: { id: todo.get('id'), owner }}])}
288+
/>
289+
)
290+
}
291+
292+
const TodoDelete = ({ todo }) => {
293+
const [transact] = useTransact()
294+
return (
295+
<button onClick={() => transact([['retractEntity', todo.get('id')]])}>
296+
Delete
297+
</button>
298+
)
299+
}
300+
301+
const TodoFilters = () => {
302+
const [filters] = useEntity({ identity: 'todoFilters' })
303+
const [client] = useClient()
304+
return (
305+
<div>
306+
Filter by:&nbsp;&nbsp;
307+
<label>Show Completed?
308+
<input
309+
type="checkbox"
310+
checked={filters.get('showCompleted')}
311+
onChange={e => client.transactSilently([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked }}])}
312+
/>
313+
</label>
314+
&nbsp;·&nbsp;
315+
<EntitySelect
316+
label="Project"
317+
entityType="project"
318+
value={filters.get('project')}
319+
onChange={project => client.transactSilently([{ todoFilter: { id: filters.get('id'), project }}])}
320+
/>
321+
&nbsp;·&nbsp;
322+
<EntitySelect
323+
label="Owner"
324+
entityType="user"
325+
value={filters.get('owner')}
326+
onChange={owner => client.transactSilently([{ todoFilter: { id: filters.get('id'), owner }}])}
327+
/>
328+
</div>
329+
)
330+
}
331+
332+
const EntitySelect = React.memo(({ label, entityType, value, onChange }) => {
333+
const [entities] = useQuery({
334+
$find: entityType,
335+
$where: { [entityType]: { name: '$any' } }
336+
})
337+
return (
338+
<label>{label}:&nbsp;
339+
<select
340+
name={entityType}
341+
value={value || ''}
342+
onChange={e => onChange && onChange(Number(e.target.value) || null)}
343+
>
344+
<option key="-" value=""></option>
345+
{entities.map(entity => (
346+
<option key={entity.get('id')} value={entity.get('id')}>
347+
{entity.get('name')}
348+
</option>
349+
))}
350+
</select>
351+
</label>
352+
)
353+
})

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
"cz-conventional-changelog": "^3.3.0",
5151
"enzyme": "3.11.0",
5252
"enzyme-adapter-react-16": "1.15.5",
53+
"firebase": "^8.0.2",
54+
"firebaseui": "^4.7.1",
5355
"highlight.js": "10.2.1",
5456
"husky": "5.0.0-beta.0",
5557
"jest": "26.6.0",

public/css/firebaseui.css

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<title>homebase examples</title>
66
<meta charset="UTF-8">
77
<meta name="viewport" content="width=device-width, initial-scale=1">
8+
<link type="text/css" rel="stylesheet" href="css/firebaseui.css" />
89
</head>
910

1011
<body>

0 commit comments

Comments
 (0)
0