Skip to content

Commit 2cba32f

Browse files
authored
perf(entity.get): add attribute change tracking (#15)
- improve re-render performance by ~2x on light pages - improve re-render performance by ~4x on heavy pages with ~10k components - only trigger re-renders on attributes that have changed - instrument `entity.get` to track used attributes and scope them to their respective hook - update the todo example to better leverage these new performance features - add performance section to readme
1 parent 798daf0 commit 2cba32f

File tree

9 files changed

+431
-309
lines changed

9 files changed

+431
-309
lines changed

.github/workflows/publish-examples.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jobs:
4444

4545
- run: yarn install --frozen-lockfile
4646

47-
- run: yarn shadow-cljs compile dev
47+
- run: yarn shadow-cljs release dev
4848

4949
- name: Publish to GitHub Pages 🚀
5050
uses: JamesIves/github-pages-deploy-action@releases/v3

README.md

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

152+
## Performance
153+
154+
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.
155+
156+
The default caching reduces unnecessary re-renders and virtual DOM thrashing a lot. That said, it is still possible to trigger more re-renders than you might want.
157+
158+
One top level `useQuery` + prop drilling the entities it returns will cause all children to re-render on any change to the parent or their siblings.
159+
160+
To fix this we recommend passing ids to children, not whole entities. Instead get the entity in the child with `useEntity(id)`. This creates a new scope for each child so they are not affected by changes in the state of the parent or sibling components.
161+
162+
```js
163+
const TodoList = () => {
164+
const [todos] = useQuery({
165+
$find: 'todo',
166+
$where: { todo: { name: '$any' } }
167+
})
168+
return (todos.map(t => <Todo key={t.get('id')} id={t.get('id')} />))
169+
}
170+
171+
// Good
172+
const Todo = React.memo(({ id }) => {
173+
const [todo] = useEntity(id)
174+
// ...
175+
})
176+
177+
// Bad
178+
const Todo = React.memo(({ todo }) => {
179+
// ...
180+
})
181+
```
182+
152183

153184
## Docs
154185
https://www.notion.so/Homebase-Alpha-Docs-0f0e22f3adcd4e9d87a13440ab0c7a0b

js/todo-example.jsx

Lines changed: 96 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,7 @@ const config = {
2929
// identity is a special unique attribute for user generated ids
3030
// E.g. todoFilters are settings that should be easy to lookup by their identity
3131
identity: 'todoFilters',
32-
showCompleted: true,
33-
project: 0
32+
showCompleted: true
3433
}
3534
}, {
3635
user: {
@@ -110,57 +109,6 @@ const NewTodo = () => {
110109
)
111110
}
112111

113-
const TodoFilters = () => {
114-
const [filters] = useEntity({ identity: 'todoFilters' })
115-
const [transact] = useTransact()
116-
return (
117-
<div>
118-
<label htmlFor="show-completed">Show Completed?</label>
119-
<input
120-
type="checkbox"
121-
id="show-completed"
122-
checked={filters.get('showCompleted')}
123-
onChange={e => transact([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked }}])}
124-
/>
125-
&nbsp;·&nbsp;
126-
<ProjectSelect
127-
value={filters.get('project')}
128-
onChange={project => transact([{ todoFilter: { id: filters.get('id'), project }}])}
129-
/>
130-
</div>
131-
)
132-
}
133-
134-
const ProjectSelect = ({ value, onChange }) => {
135-
const [projects] = useQuery({
136-
$find: 'project',
137-
$where: { project: { name: '$any' } }
138-
})
139-
return (
140-
<>
141-
<label>
142-
Project:
143-
</label>
144-
&nbsp;
145-
<select
146-
name="projects"
147-
value={value}
148-
onChange={e => onChange && onChange(Number(e.target.value))}
149-
>
150-
<option value="0"></option>
151-
{projects.map(project => (
152-
<option
153-
key={project.get('id')}
154-
value={project.get('id')}
155-
>
156-
{project.get('name')}
157-
</option>
158-
))}
159-
</select>
160-
</>
161-
)
162-
}
163-
164112
const TodoList = () => {
165113
const [filters] = useEntity({ identity: 'todoFilters' })
166114
const [todos] = useQuery({
@@ -169,36 +117,42 @@ const TodoList = () => {
169117
})
170118
return (
171119
<div>
172-
{todos
173-
.filter(todo => {
120+
{todos.filter(todo => {
174121
if (!filters.get('showCompleted') && todo.get('isCompleted')) return false
175122
if (filters.get('project') && todo.get('project', 'id') !== filters.get('project')) return false
123+
if (filters.get('owner') && todo.get('owner', 'id') !== filters.get('owner')) return false
176124
return true
177-
})
178-
.sort((a, b) => a.get('createdAt') > b.get('createdAt') ? -1 : 1)
179-
.map(todo => <Todo key={todo.get('id')} todo={todo}/>)}
125+
}).sort((a, b) => a.get('createdAt') > b.get('createdAt') ? -1 : 1)
126+
.map(todo => <Todo key={todo.get('id')} id={todo.get('id')}/>)}
180127
</div>
181128
)
182129
}
183130

184-
const Todo = ({ todo }) => (
185-
<div>
186-
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-end', paddingTop: 20}}>
187-
<TodoCheck todo={todo} />
188-
<TodoName todo={todo} />
189-
</div>
131+
// PERFORMANCE: By accepting an `id` prop instead of a whole `todo` entity
132+
// this component stays disconnected from the useQuery in the parent TodoList.
133+
// useEntity creates a separate scope for every Todo so changes to TodoList
134+
// or sibling Todos don't trigger unnecessary re-renders.
135+
const Todo = React.memo(({ id }) => {
136+
const [todo] = useEntity(id)
137+
return (
190138
<div>
191-
<TodoProject todo={todo} />
192-
&nbsp;·&nbsp;
193-
<TodoOwner todo={todo} />
194-
&nbsp;·&nbsp;
195-
<TodoDelete todo={todo} />
139+
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-end', paddingTop: 20}}>
140+
<TodoCheck todo={todo} />
141+
<TodoName todo={todo} />
142+
</div>
143+
<div>
144+
<TodoProject todo={todo} />
145+
&nbsp;·&nbsp;
146+
<TodoOwner todo={todo} />
147+
&nbsp;·&nbsp;
148+
<TodoDelete todo={todo} />
149+
</div>
150+
<small style={{ color: 'grey' }}>
151+
{todo.get('createdAt').toLocaleString()}
152+
</small>
196153
</div>
197-
<small style={{ color: 'grey' }}>
198-
{todo.get('createdAt').toLocaleString()}
199-
</small>
200-
</div>
201-
)
154+
)
155+
})
202156

203157
const TodoCheck = ({ todo }) => {
204158
const [transact] = useTransact()
@@ -207,12 +161,7 @@ const TodoCheck = ({ todo }) => {
207161
type="checkbox"
208162
style={{ width: 20, height: 20, cursor: 'pointer' }}
209163
checked={!!todo.get('isCompleted')}
210-
onChange={e => transact([{
211-
todo: {
212-
id: todo.get('id'),
213-
isCompleted: e.target.checked
214-
}
215-
}])}
164+
onChange={e => transact([{ todo: { id: todo.get('id'), isCompleted: e.target.checked } }])}
216165
/>
217166
)
218167
}
@@ -222,10 +171,10 @@ const TodoName = ({ todo }) => {
222171
return (
223172
<input
224173
style={{
225-
border: 'none', fontSize: 20, marginTop: -2, cursor: 'pointer',
174+
border: 'none', fontSize: 20, marginTop: -2, cursor: 'pointer',
226175
...todo.get('isCompleted') && { textDecoration: 'line-through '}
227176
}}
228-
value={todo.get('name')}
177+
defaultValue={todo.get('name')}
229178
onChange={e => transact([{ todo: { id: todo.get('id'), name: e.target.value }}])}
230179
/>
231180
)
@@ -234,41 +183,24 @@ const TodoName = ({ todo }) => {
234183
const TodoProject = ({ todo }) => {
235184
const [transact] = useTransact()
236185
return (
237-
<ProjectSelect
238-
value={todo.get('project', 'id') || ''}
239-
onChange={projectId => transact([{ todo: { id: todo.get('id'), 'project': projectId || null }}])}
186+
<EntitySelect
187+
label="Project"
188+
entityType="project"
189+
value={todo.get('project', 'id')}
190+
onChange={project => transact([{ todo: { id: todo.get('id'), project }}])}
240191
/>
241192
)
242193
}
243194

244195
const TodoOwner = ({ todo }) => {
245196
const [transact] = useTransact()
246-
const [users] = useQuery({
247-
$find: 'user',
248-
$where: { user: { name: '$any' } }
249-
})
250197
return (
251-
<>
252-
<label>
253-
Owner:
254-
</label>
255-
&nbsp;
256-
<select
257-
name="users"
258-
value={todo.get('owner', 'id') || ''}
259-
onChange={e => transact([{ todo: { id: todo.get('id'), owner: Number(e.target.value) || null }}])}
260-
>
261-
<option value=""></option>
262-
{users.map(user => (
263-
<option
264-
key={user.get('id')}
265-
value={user.get('id')}
266-
>
267-
{user.get('name')}
268-
</option>
269-
))}
270-
</select>
271-
</>
198+
<EntitySelect
199+
label="Owner"
200+
entityType="user"
201+
value={todo.get('owner', 'id')}
202+
onChange={owner => transact([{ todo: { id: todo.get('id'), owner }}])}
203+
/>
272204
)
273205
}
274206

@@ -279,4 +211,57 @@ const TodoDelete = ({ todo }) => {
279211
Delete
280212
</button>
281213
)
282-
}
214+
}
215+
216+
const TodoFilters = () => {
217+
const [filters] = useEntity({ identity: 'todoFilters' })
218+
const [transact] = useTransact()
219+
return (
220+
<div>
221+
<label>Show Completed?
222+
<input
223+
type="checkbox"
224+
checked={filters.get('showCompleted')}
225+
onChange={e => transact([{ todoFilter: { id: filters.get('id'), showCompleted: e.target.checked }}])}
226+
/>
227+
</label>
228+
&nbsp;·&nbsp;
229+
<EntitySelect
230+
label="Project"
231+
entityType="project"
232+
value={filters.get('project')}
233+
onChange={project => transact([{ todoFilter: { id: filters.get('id'), project }}])}
234+
/>
235+
&nbsp;·&nbsp;
236+
<EntitySelect
237+
label="Owner"
238+
entityType="user"
239+
value={filters.get('owner')}
240+
onChange={owner => transact([{ todoFilter: { id: filters.get('id'), owner }}])}
241+
/>
242+
</div>
243+
)
244+
}
245+
246+
const EntitySelect = React.memo(({ label, entityType, value, onChange }) => {
247+
const [entities] = useQuery({
248+
$find: entityType,
249+
$where: { [entityType]: { name: '$any' } }
250+
})
251+
return (
252+
<label>{label}:&nbsp;
253+
<select
254+
name={entityType}
255+
value={value || ''}
256+
onChange={e => onChange && onChange(Number(e.target.value) || null)}
257+
>
258+
<option key="-" value=""></option>
259+
{entities.map(entity => (
260+
<option key={entity.get('id')} value={entity.get('id')}>
261+
{entity.get('name')}
262+
</option>
263+
))}
264+
</select>
265+
</label>
266+
)
267+
})

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"test:js": "yarn build && jest js/tests",
1414
"test:js:dev": "yarn build:dev && jest js/tests",
1515
"test:cljs": "shadow-cljs compile test && node out/node-tests.js",
16+
"test:cljs:watch": "shadow-cljs watch test-autorun",
1617
"test": "yarn test:cljs && yarn test:js",
1718
"test:dev": "yarn test:cljs && yarn test:js:dev",
1819
"report": "rm -rf dist && shadow-cljs run shadow.cljs.build-report npm report.html",

shadow-cljs.edn

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
:output-to "out/node-tests.js"
2424
:ns-regexp "-test$"
2525
:autorun false}
26+
:test-autorun {:target :node-test
27+
:output-to "out/node-tests.js"
28+
:ns-regexp "-test$"
29+
:autorun true}
2630
:npm {:target :npm-module
2731
:output-dir "dist/js"
2832
:entries [homebase.react]

0 commit comments

Comments
 (0)