Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/routes/hook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const meta: MetaFunction = () => {
export default function HookPage() {
const {
count,
submodel: { title, allCaps},
submodel: { title, allCaps, lowercase },
} = useObservable(store);

console.log("Rendering")
Expand All @@ -25,7 +25,7 @@ export default function HookPage() {
{count}: {title}
</p>
<p className="text-gray-800 my-2">This is a computed view that capitalizes the title: {allCaps}</p>
{/* <p>This is a lazily evaluated view that converts the title to lowercase: {lowercase()}</p> */}
<p className="text-gray-800 my-2">This is a lazily evaluated view that converts the title to lowercase: {lowercase()}</p>
<div className="flex flex-wrap gap-2">
<button
onClick={() => store.increment()}
Expand Down
3 changes: 2 additions & 1 deletion app/routes/observer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const meta: MetaFunction = () => {
export default observer(function ObserverPage() {
const {
count,
submodel: { title, allCaps },
submodel: { title, allCaps, lowercase },
} = store;

console.log("Rendering")
Expand All @@ -25,6 +25,7 @@ export default observer(function ObserverPage() {
{count}: {title}
</p>
<p className="text-gray-800 my-2">This is a computed view that capitalizes the title: {allCaps}</p>
<p className="text-gray-800 my-2">This is a lazily evaluated view that converts the title to lowercase: {lowercase()}</p>
<div className="flex flex-wrap gap-2">
<button
onClick={() => store.increment()}
Expand Down
6 changes: 5 additions & 1 deletion app/routes/state.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MetaFunction } from "@remix-run/node";
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback } from "react";

export const meta: MetaFunction = () => {
return [
Expand All @@ -17,6 +17,9 @@ export default function StatePage() {
// Equivalent to MST computed view
const allCaps = useMemo(() => title.toUpperCase(), [title]);

// Equivalent to MST lazily evaluated view
const lowercase = useCallback(() => title.toLowerCase(), [title]);

console.log("Rendering");

const increment = () => {
Expand Down Expand Up @@ -50,6 +53,7 @@ export default function StatePage() {
{count}: {title}
</p>
<p className="text-gray-800 my-2">This is a memoized value that capitalizes the title: {allCaps}</p>
<p className="text-gray-800 my-2">This is a lazily evaluated view that converts the title to lowercase: {lowercase()}</p>
<div className="flex flex-wrap gap-2">
<button
onClick={increment}
Expand Down
184 changes: 82 additions & 102 deletions hooks/useObservable.ts
Original file line number Diff line number Diff line change
@@ -1,117 +1,97 @@
import { useEffect, useState } from "react";
import { IStateTreeNode, getSnapshot } from "mobx-state-tree";
import { reaction } from "mobx";
import { IStateTreeNode, getMembers, isStateTreeNode } from "mobx-state-tree";
import { useCallback, useLayoutEffect, useRef, useState } from "react";

function isMSTNode(value: unknown): value is IStateTreeNode {
return Boolean(value && typeof value === "object" && "toJSON" in value);
}
const emptyObject = {};

export function useObservable<T extends IStateTreeNode>(model: T) {
type SnapshotType = ReturnType<typeof getSnapshot<T>>;

const [state, setState] = useState<Record<string | symbol, unknown>>({});

useEffect(() => {
if (!model) return;

const disposers: ReturnType<typeof reaction>[] = [];

// Set up reaction for each property
Object.keys(state).forEach(prop => {
const disposer = reaction(
() => {
const value = model[prop as keyof T];
if (isMSTNode(value)) {
// Handle nested properties
const nested = {} as Record<string, unknown>;
Object.keys(state)
.filter(key => key.startsWith(`${prop}.`))
.forEach(key => {
const nestedProp = key.split('.')[1];
nested[nestedProp] = value[nestedProp as keyof typeof value];
});
return { value, ...nested };
}
return value;
},
(newValue) => {
setState(prev => ({
...prev,
// @ts-expect-error - need to figure out some of this weird typing
[prop]: isMSTNode(newValue) ? newValue.value : newValue
}));
},
{ fireImmediately: true }
);
disposers.push(disposer);
});

return () => disposers.forEach(d => d());
}, [model, Object.keys(state).join(',')]);

return new Proxy({} as SnapshotType, {
get: (_, property: string | symbol) => {
// Initialize tracking for new properties
if (!(property in state)) {
const value = model[property as keyof T];

setState(prev => ({
...prev,
[property]: value
}));

if (typeof value === 'function') {
return value.bind(model);
}

if (isMSTNode(value)) {
return new Proxy({} as SnapshotType, {
get: (_, nestedProp: string | symbol) => {
const nestedValue = value[nestedProp as keyof typeof value];

if (typeof nestedValue === 'function') {
return nestedValue.bind(value);
}

// Track nested property
const fullProp = `${String(property)}.${String(nestedProp)}`;
if (!(fullProp in state)) {
setState(prev => ({
...prev,
[fullProp]: nestedValue
}));
}

return nestedValue;
const [, setState] = useState(1);
const forceRender = useCallback(() => setState((n) => -n), []);

const pathsAccessedInRender = useRef<Set<string[]>>(new Set());
pathsAccessedInRender.current = new Set();

useLayoutEffect(() => {
const disposer = reaction(
function expression() {
//// Uncomment this to see what paths are being accessed in the render

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: let's change this to a logging function that we can configure with an optional argument to useObservable. Maybe just like useObservable<T extends IStateTreeNode>(model: T, debug?: boolean), and then if debug is true, we run these logs?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got that locally, will push up tomorrow.


// console.log("Paths accessed in render:");
// pathsAccessedInRender.current.forEach((pathParts) => {
// console.log(" ", pathParts.join("."));
// if (Array.isArray(pathParts.args)) {
// console.log(" This is a view fn, with args:", pathParts.args);
// }
// });

for (const pathParts of pathsAccessedInRender.current) {
let current = model;
for (const part of pathParts) {
current = current[part];

// If we are accessing a view function, we attach the args
// to the path array so we can call the view function here
if (
typeof current === "function" &&
Array.isArray(pathParts.args)
) {
current = current(...pathParts.args);
}
});
}
}

return value;
return []; // If anything changes we want to re-render
},
function effect() {
forceRender();
},
{
fireImmediately: false,
}
);

const value = model[property as keyof T];
return () => {
disposer();
};
}, [forceRender, model]);

if (typeof value === 'function') {
return value.bind(model);
}
function makeProxy(instance, path) {
return new Proxy(emptyObject, {
get(_, key) {
const value = instance[key];

if (isMSTNode(value)) {
return new Proxy({} as SnapshotType, {
get: (_, nestedProp: string | symbol) => {
const nestedValue = value[nestedProp as keyof typeof value];

if (typeof nestedValue === 'function') {
return nestedValue.bind(value);
}
const newPath = [...path, key];
pathsAccessedInRender.current.add(newPath);

if (isStateTreeNode(value)) {
return makeProxy(value, [...path, key]);
}

const fullProp = `${String(property)}.${String(nestedProp)}`;
return state[fullProp] ?? nestedValue;
if (typeof value === "function") {
const members = getMembers(instance);
if (members.views.includes(key)) {
// If we are accessing a view function, we attach the args
// to the path array so we can call the view function in the
// reaction.

const view = value.bind(instance);
const argumentWatcher = (...args: any) => {
const newPath = [...path, key];
newPath.args = args;
pathsAccessedInRender.current.add(newPath);
return view(...args);
};

return argumentWatcher;
} else {
return value.bind(instance);
}
});
}
}

return value;
},
});
}

return state[property] ?? value;
}
});
return makeProxy(model, []);
}