Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/app-framework/redux-hooks.ts
5808 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020-2026 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Overview
8
--------
9
This file defines the core React hooks for reading Redux-like stores in the
10
frontend. There are three usage shapes:
11
12
1) Named/global store:
13
const accountId = useRedux(["account", "account_id"]);
14
15
2) Project store:
16
const title = useRedux(["settings", "title"], projectId);
17
18
3) Editor store in a project:
19
const cursor = useRedux(["cursor"], projectId, path);
20
21
Typed hook wrapper:
22
const projectState = useTypedRedux({ project_id: projectId }, "status");
23
const pageState = useTypedRedux("page", "current_tab");
24
25
Editor selector hook:
26
const useEditor = useEditorRedux<MyEditorState>({ project_id, path });
27
const tasks = useEditor("tasks");
28
const pages = useEditor("pages");
29
30
If the store name is not yet known, you may use "" to get undefined:
31
useRedux(["", "whatever"]) === undefined
32
33
Implementation Notes
34
--------------------
35
- All hooks are called unconditionally and keep a stable order to satisfy
36
react-hooks/rules-of-hooks.
37
- Subscriptions listen to the store "change" event and compare values by
38
reference. Immutable stores are expected to update references on changes.
39
- useRedux normalizes arguments into a tagged target and uses a single
40
subscription path.
41
- useEditorRedux returns a selector function that tracks which fields were
42
read during render and only re-renders when those fields change. This keeps
43
hook usage valid while preserving per-field change detection.
44
*/
45
46
import React, { useCallback, useEffect, useLayoutEffect, useRef } from "react";
47
48
import {
49
ProjectActions,
50
ProjectStore,
51
redux,
52
} from "@cocalc/frontend/app-framework";
53
import * as types from "@cocalc/frontend/app-framework/actions-and-stores";
54
import { ProjectStoreState } from "@cocalc/frontend/project_store";
55
import { is_valid_uuid_string } from "@cocalc/util/misc";
56
57
export interface StoreStates {
58
account: types.AccountState;
59
"admin-site-licenses": types.SiteLicensesState;
60
"admin-users": types.AdminUsersState;
61
billing: types.BillingState;
62
compute_images: types.ComputeImagesState;
63
customize: types.CustomizeState;
64
file_use: types.FileUseState;
65
mentions: types.MentionsState;
66
messages: types.MessagesState;
67
page: types.PageState;
68
projects: types.ProjectsState;
69
users: types.UsersState;
70
news: types.NewsState;
71
}
72
73
/**
74
* Typed wrapper around useRedux.
75
*
76
* Use this for safer typing when possible. The overloads enforce which
77
* store is being accessed and the field name within that store.
78
*
79
* Examples:
80
* const pageTab = useTypedRedux("page", "current_tab");
81
* const status = useTypedRedux({ project_id }, "status");
82
*/
83
export function useTypedRedux<
84
T extends keyof StoreStates,
85
S extends keyof StoreStates[T],
86
>(store: T, field: S): StoreStates[T][S];
87
88
export function useTypedRedux<S extends keyof ProjectStoreState>(
89
project_id: { project_id: string },
90
field: S,
91
): ProjectStoreState[S];
92
93
export function useTypedRedux(
94
a: keyof StoreStates | { project_id: string },
95
field: string,
96
) {
97
const path = typeof a === "string" ? a : a.project_id;
98
return useRedux(path, field);
99
}
100
101
/**
102
* Read a field from an editor store regardless of the underlying store API.
103
*
104
* This supports Immutable-style stores that expose getIn/get, as well as
105
* plain object stores. It returns undefined for missing stores/fields.
106
*/
107
function getEditorFieldValue(store: any, field: string) {
108
if (store == null) return undefined;
109
if (typeof store.getIn === "function") {
110
return store.getIn([field]);
111
}
112
if (typeof store.get === "function") {
113
return store.get(field);
114
}
115
return store[field];
116
}
117
118
/**
119
* Hook that returns a selector for editor store fields.
120
*
121
* The returned function is NOT a hook. Call it during render to read fields
122
* and to register which fields this component depends on.
123
*
124
* Example:
125
* const useEditor = useEditorRedux<MyEditorState>({ project_id, path });
126
* const tasks = useEditor("tasks");
127
* const pages = useEditor("pages");
128
*
129
* Implementation details:
130
* - Tracks fields read during render (renderFieldsRef).
131
* - After render (useLayoutEffect), snapshots those fields into
132
* trackedFieldsRef and caches their latest values.
133
* - A single store subscription compares only tracked fields and triggers
134
* a re-render when any of them changes.
135
* - Handles editor store creation being delayed by subscribing to the
136
* global redux store until the editor store exists.
137
*/
138
export function useEditorRedux<State>(editor: {
139
project_id: string;
140
path: string;
141
}) {
142
const [, forceRender] = React.useState(0);
143
const storeRef = useRef<any>(
144
redux.getEditorStore(editor.project_id, editor.path),
145
);
146
const trackedFieldsRef = useRef<Set<string>>(new Set());
147
const lastValuesRef = useRef<Map<string, any>>(new Map());
148
const renderFieldsRef = useRef<Set<string>>(new Set());
149
const editorKeyRef = useRef<string>("");
150
151
const editorKey = `${editor.project_id}:${editor.path}`;
152
if (editorKeyRef.current !== editorKey) {
153
editorKeyRef.current = editorKey;
154
trackedFieldsRef.current = new Set();
155
lastValuesRef.current = new Map();
156
}
157
158
storeRef.current = redux.getEditorStore(editor.project_id, editor.path);
159
renderFieldsRef.current = new Set();
160
161
const selectField = useCallback(<S extends keyof State>(field: S) => {
162
renderFieldsRef.current.add(field as string);
163
return getEditorFieldValue(storeRef.current, field as string) as State[S];
164
}, []);
165
166
useLayoutEffect(() => {
167
const fields = renderFieldsRef.current;
168
trackedFieldsRef.current = fields;
169
const store = storeRef.current;
170
const lastValues = lastValuesRef.current;
171
for (const field of Array.from(lastValues.keys())) {
172
if (!fields.has(field)) {
173
lastValues.delete(field);
174
}
175
}
176
if (store != null) {
177
for (const field of fields) {
178
lastValues.set(field, getEditorFieldValue(store, field));
179
}
180
}
181
});
182
183
useEffect(() => {
184
let store = redux.getEditorStore(editor.project_id, editor.path);
185
storeRef.current = store;
186
let is_mounted = true;
187
let unsubscribe: (() => void) | undefined;
188
189
const update = (obj) => {
190
if (obj == null || !is_mounted) return;
191
storeRef.current = obj;
192
const fields = trackedFieldsRef.current;
193
if (fields.size === 0) return;
194
let changed = false;
195
const lastValues = lastValuesRef.current;
196
for (const field of fields) {
197
const newValue = getEditorFieldValue(obj, field);
198
if (lastValues.get(field) !== newValue) {
199
lastValues.set(field, newValue);
200
changed = true;
201
}
202
}
203
if (changed) {
204
forceRender((version) => version + 1);
205
}
206
};
207
208
if (store != null) {
209
store.on("change", update);
210
update(store);
211
} else {
212
const g = () => {
213
if (!is_mounted) {
214
unsubscribe?.();
215
return;
216
}
217
store = redux.getEditorStore(editor.project_id, editor.path);
218
if (store != null) {
219
unsubscribe?.();
220
storeRef.current = store;
221
update(store); // may have missed an initial change
222
store.on("change", update);
223
}
224
};
225
unsubscribe = redux.reduxStore.subscribe(g);
226
}
227
228
return () => {
229
is_mounted = false;
230
store?.removeListener("change", update);
231
unsubscribe?.();
232
};
233
}, [editor.project_id, editor.path]);
234
235
return selectField;
236
}
237
238
type ReduxTarget =
239
| { kind: "named"; path: string[] }
240
| { kind: "project"; path: string[]; project_id: string }
241
| { kind: "editor"; path: string[]; project_id: string; filename: string };
242
243
/**
244
* Normalize useRedux arguments into a tagged target.
245
*
246
* Rules:
247
* - String path + string project_id => named store or project store
248
* - Array path + project_id => project store (if uuid) or named store
249
* - Array path + project_id + filename => editor store
250
*/
251
function normalizeReduxArgs(
252
path: string | string[],
253
project_id?: string,
254
filename?: string,
255
): ReduxTarget {
256
if (typeof path === "string") {
257
// good typed version!! -- path specifies store
258
if (typeof project_id !== "string" || typeof filename !== "undefined") {
259
throw Error(
260
"if first argument of useRedux is a string then second argument must also be and no other arguments can be specified",
261
);
262
}
263
if (is_valid_uuid_string(path)) {
264
return { kind: "project", path: [project_id], project_id: path };
265
}
266
return { kind: "named", path: [path, project_id] };
267
}
268
if (project_id == null) {
269
return { kind: "named", path };
270
}
271
if (filename == null) {
272
if (!is_valid_uuid_string(project_id)) {
273
// this is used a lot by frame-tree editors right now.
274
return { kind: "named", path: [project_id].concat(path) };
275
}
276
return { kind: "project", path, project_id };
277
}
278
return { kind: "editor", path, project_id, filename };
279
}
280
281
/**
282
* Read the current snapshot for a normalized target.
283
*
284
* This does not subscribe; it is used for initial state and for comparing
285
* store updates inside the subscription.
286
*/
287
function getReduxValue(target: ReduxTarget) {
288
if (target.kind === "named") {
289
if (target.path[0] === "") {
290
return undefined;
291
}
292
return redux.getStore(target.path[0])?.getIn(target.path.slice(1) as any);
293
}
294
if (target.kind === "project") {
295
return redux
296
.getProjectStore(target.project_id)
297
.getIn(target.path as [string, string, string, string, string]);
298
}
299
return redux
300
.getEditorStore(target.project_id, target.filename)
301
?.getIn(target.path as [string, string, string, string, string]);
302
}
303
304
/**
305
* General-purpose hook to read values from named stores, project stores, or
306
* editor stores. The hook decides which store to subscribe to based on the
307
* argument shape (see examples below).
308
*
309
* Examples:
310
* const userName = useRedux(["account", "full_name"]);
311
* const status = useRedux(["status"], projectId);
312
* const cursor = useRedux(["cursor"], projectId, path);
313
* const maybe = useRedux(["", "unknown"]) // => undefined
314
*
315
* Implementation details:
316
* - Arguments are normalized to a target so hooks are not called conditionally.
317
* - A single useEffect subscribes to the correct store based on target.kind.
318
* - Updates compare by reference; immutable stores should update references.
319
*/
320
export function useRedux(
321
path: string | string[],
322
project_id?: string,
323
filename?: string,
324
) {
325
const target = normalizeReduxArgs(path, project_id, filename);
326
// Stable key: normalizeReduxArgs creates a deterministic shape for JSON.stringify.
327
const targetKey = JSON.stringify(target);
328
const [value, set_value] = React.useState(() => getReduxValue(target));
329
330
useEffect(() => {
331
let store: any;
332
let last_value = getReduxValue(target);
333
let is_mounted = true;
334
set_value(() => last_value);
335
336
const update = (obj) => {
337
if (obj == null || !is_mounted) return;
338
const subpath =
339
target.kind === "named" ? target.path.slice(1) : target.path;
340
const new_value = obj.getIn(subpath as any);
341
if (last_value !== new_value) {
342
last_value = new_value;
343
set_value(() => new_value);
344
}
345
};
346
347
if (target.kind === "named") {
348
if (target.path[0] === "") {
349
return () => {
350
is_mounted = false;
351
};
352
}
353
store = redux.getStore(target.path[0]);
354
if (store == null) {
355
console.warn(
356
`store "${target.path[0]}" must exist; path=`,
357
target.path,
358
);
359
return () => {
360
is_mounted = false;
361
};
362
}
363
store.on("change", update);
364
update(store);
365
return () => {
366
is_mounted = false;
367
store?.removeListener("change", update);
368
};
369
}
370
371
if (target.kind === "project") {
372
store = redux.getProjectStore(target.project_id);
373
store.on("change", update);
374
update(store);
375
return () => {
376
is_mounted = false;
377
store?.removeListener("change", update);
378
};
379
}
380
381
let editorStore = redux.getEditorStore(target.project_id, target.filename);
382
let unsubscribe: (() => void) | undefined;
383
const f = (obj) => {
384
if (obj == null || !is_mounted) return;
385
const new_value = obj.getIn(target.path);
386
if (last_value !== new_value) {
387
last_value = new_value;
388
set_value(() => new_value);
389
}
390
};
391
f(editorStore);
392
if (editorStore != null) {
393
editorStore.on("change", f);
394
} else {
395
const g = () => {
396
if (!is_mounted) {
397
unsubscribe?.();
398
return;
399
}
400
editorStore = redux.getEditorStore(target.project_id, target.filename);
401
if (editorStore != null) {
402
unsubscribe?.();
403
f(editorStore); // may have missed an initial change
404
editorStore.on("change", f);
405
}
406
};
407
unsubscribe = redux.reduxStore.subscribe(g);
408
}
409
410
return () => {
411
is_mounted = false;
412
editorStore?.removeListener("change", f);
413
unsubscribe?.();
414
};
415
}, [targetKey]);
416
417
return value;
418
}
419
420
/**
421
* Hook to get actions for a named store, a project, or an editor.
422
*
423
* Examples:
424
* const actions = useActions("projects");
425
* const actions = useActions({ project_id });
426
* const editorActions = useActions(projectId, path);
427
*
428
* Notes:
429
* - Named actions must exist; missing named actions throw an error.
430
* - Project actions can be undefined while a project is closing.
431
*/
432
433
export function useActions(name: "account"): types.AccountActions;
434
export function useActions(
435
name: "admin-site-licenses",
436
): types.SiteLicensesActions;
437
export function useActions(name: "admin-users"): types.AdminUsersActions;
438
export function useActions(name: "billing"): types.BillingActions;
439
export function useActions(name: "file_use"): types.FileUseActions;
440
export function useActions(name: "mentions"): types.MentionsActions;
441
export function useActions(name: "messages"): types.MessagesActions;
442
export function useActions(name: "page"): types.PageActions;
443
export function useActions(name: "projects"): types.ProjectsActions;
444
export function useActions(name: "users"): types.UsersActions;
445
export function useActions(name: "news"): types.NewsActions;
446
export function useActions(name: "customize"): types.CustomizeActions;
447
448
// If it is none of the explicitly named ones... it's a project or just some general actions.
449
// That said *always* use {project_id} as below to get the actions for a project, so you
450
// get proper typing.
451
export function useActions(x: string): any;
452
453
export function useActions<T>(x: { name: string }): T;
454
455
// Return type includes undefined because the actions for a project *do* get
456
// destroyed when closing a project, and rendering can still happen during this
457
// time, so client code must account for this.
458
export function useActions(x: {
459
project_id: string;
460
}): ProjectActions | undefined;
461
462
// Or an editor actions (any for now)
463
export function useActions(x: string, path: string): any;
464
465
export function useActions(x, path?: string) {
466
return React.useMemo(() => {
467
let actions;
468
if (path != null) {
469
actions = redux.getEditorActions(x, path);
470
} else {
471
if (x?.name != null) {
472
actions = redux.getActions(x.name);
473
} else if (x?.project_id != null) {
474
// return here to avoid null check below; it can be null
475
return redux.getProjectActions(x.project_id);
476
} else if (is_valid_uuid_string(x)) {
477
// return here to avoid null check below; it can be null
478
return redux.getProjectActions(x);
479
} else {
480
actions = redux.getActions(x);
481
}
482
}
483
if (actions == null) {
484
throw Error(`BUG: actions for "${path}" must be defined but is not`);
485
}
486
return actions;
487
}, [x, path]);
488
}
489
490
// WARNING: I tried to define this Stores interface
491
// in actions-and-stores.ts but it did NOT work. All
492
// the types just became any or didn't match. Don't
493
// move this unless you also fully test it!!
494
import { Store } from "@cocalc/util/redux/Store";
495
import { isEqual } from "lodash";
496
export interface Stores {
497
account: types.AccountStore;
498
"admin-site-licenses": types.SiteLicensesStore;
499
"admin-users": types.AdminUsersStore;
500
billing: types.BillingStore;
501
compute_images: types.ComputeImagesStore;
502
customize: types.CustomizeStore;
503
file_use: types.FileUseStore;
504
mentions: types.MentionsStore;
505
messages: types.MessagesStore;
506
page: types.PageStore;
507
projects: types.ProjectsStore;
508
users: types.UsersStore;
509
news: types.NewsStore;
510
}
511
512
// If it is none of the explicitly named ones... it's a project.
513
//export function useStore(name: "projects"): types.ProjectsStore;
514
/**
515
* Hook to get a store instance (named or project).
516
*
517
* Examples:
518
* const store = useStore("projects");
519
* const store = useStore({ project_id });
520
*
521
* Throws if the store is not defined.
522
*/
523
export function useStore<T extends keyof Stores>(name: T): Stores[T];
524
export function useStore(x: { project_id: string }): ProjectStore;
525
export function useStore<T>(x: { name: string }): T;
526
// Or an editor store (any for now):
527
//export function useStore(project_id: string, path: string): Store<any>;
528
export function useStore(x): any {
529
return React.useMemo(() => {
530
let store;
531
if (x?.project_id != null) {
532
store = redux.getProjectStore(x.project_id);
533
} else if (x?.name != null) {
534
store = redux.getStore(x.name);
535
} else if (is_valid_uuid_string(x)) {
536
store = redux.getProjectStore(x);
537
} else {
538
store = redux.getStore(x);
539
}
540
if (store == null) {
541
throw Error("store must be defined");
542
}
543
return store;
544
}, [x]) as Store<any>;
545
}
546
547
/**
548
* Debug hook that logs which props changed between renders.
549
*
550
* Uses deep equality (lodash isEqual) to detect changes and logs a map of
551
* keys to [previous, next] values.
552
*/
553
export function useTraceUpdate(props) {
554
const prev = useRef(props);
555
useEffect(() => {
556
const changedProps = Object.entries(props).reduce((ps, [k, v]) => {
557
if (!isEqual(prev.current[k], v)) {
558
ps[k] = [prev.current[k], v];
559
}
560
return ps;
561
}, {});
562
if (Object.keys(changedProps).length > 0) {
563
console.log("Changed props:", changedProps);
564
}
565
prev.current = props;
566
});
567
}
568
569