S
SolidJS2mo ago
QWu4xYV

Reactivity in single cell in nested For tags

I have a large-ish (~20x20) table with cells that update very regularly. When only a single cell value changes, I would like to update only that cell, not all the cells in the row. I'm currently implementing this with nested For loops, but it seems like the inner loop always re-renders (which makes sense because the For children function gets called). Simplified example here: https://stackblitz.com/edit/solidjs-templates-jebc3v?file=src%2FApp.tsx I'm assuming there's a straightforward way to do this, but I can't see anything in the support section, docs, or github issues. Thanks for any help!
QWu4xYV
StackBlitz
Nested For - StackBlitz
Vite + solid templates
22 Replies
peerreynders
peerreynders2mo ago
To address any followup problems you'll probably need to restructure your data.
const App: Component = () => {
const [data, setData] = createStore([
{ a: 1, b: 2, c: 3 },
{ a: 4, b: 5, c: 6 },
{ a: 7, b: 8, c: 9 },
]);

const increment = () => setData(1, 'b', (prev) => prev + 1);

return (
<div>
<Table data={data} />
<button onClick={() => increment()}>Increment</button>
</div>
);
};

const Table = (props: { data: Array<{ a: number; b: number; c: number }> }) => {
return (
<For each={props.data}>
{(row) => (
<div>
<For each={Object.keys(row) as Array<keyof typeof row>}>
{(key) => {
console.log(key);
return <span>{row[key]}</span>;
}}
</For>
</div>
)}
</For>
);
};
const App: Component = () => {
const [data, setData] = createStore([
{ a: 1, b: 2, c: 3 },
{ a: 4, b: 5, c: 6 },
{ a: 7, b: 8, c: 9 },
]);

const increment = () => setData(1, 'b', (prev) => prev + 1);

return (
<div>
<Table data={data} />
<button onClick={() => increment()}>Increment</button>
</div>
);
};

const Table = (props: { data: Array<{ a: number; b: number; c: number }> }) => {
return (
<For each={props.data}>
{(row) => (
<div>
<For each={Object.keys(row) as Array<keyof typeof row>}>
{(key) => {
console.log(key);
return <span>{row[key]}</span>;
}}
</For>
</div>
)}
</For>
);
};
1. In increment only change what you need to
peerreynders
peerreynders2mo ago
2. In this context row[key] (effectively props.data[index()][key]) will work. Just keep in mind the proxies have limitations, so using functions/methods that work on un-proxied arrays/objects may introduce unwanted/unexpected behaviour.
MDN Web Docs
Proxy - JavaScript | MDN
The Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.
peerreynders
peerreynders2mo ago
3. The get the benefit of a reactive effect of Solid's JSX wrap it in a <span> otherwise it just becomes an inert string.
Dakotys
Dakotys2mo ago
You are just using For instead of Index loop. https://www.solidjs.com/tutorial/flow_index#:~:text=when%20using%20%3Cfor%3E%20with%20primitive%20values%20or%20arrays%20of%20arrays%2C%20we%20could%20cause%20a%20lot%20of%20unnecessary%20rendering. @QWu4xYV here is implementation:
const Table = (props: { data: Array<{ a: number; b: number; c: number }> }) => {
return (
<Index each={props.data}>
{(row) =>
(console.log(row()) as any) ||
<div>
<For each={Object.values(row())}>
{(value) =>
(console.log({ value }) as any) ||
String(value)
}
</For>
</div>
}
</Index>
);
};
const Table = (props: { data: Array<{ a: number; b: number; c: number }> }) => {
return (
<Index each={props.data}>
{(row) =>
(console.log(row()) as any) ||
<div>
<For each={Object.values(row())}>
{(value) =>
(console.log({ value }) as any) ||
String(value)
}
</For>
</div>
}
</Index>
);
};
If you struggle with understanding the difference between For and Index loop check out List loop, it combines the best from both worlds. https://github.com/solidjs-community/solid-primitives/tree/main/packages/list
QWu4xYV
QWu4xYV2mo ago
Thank you both, that was super helpful! Two main takeaways for me: 1. @peerreynders , I had previously had increment just updating what I needed to, but because I didn't realize about #3, I was confused about why it wasn't updating. Thanks for pointing that out! 2. @Dakotys , tbh, I just didn't realize that the <Index> component existed at all. I just looked through the API reference and saw the <For> component and used that. Thanks for pointing me in the right direction! Not sure the general docs philosophy, but maybe a short sentence at the end of the API docs for <For> like this could be helpful: "<Index>(link here) is another component used for rendering lists, check out this section of the tutorial for more info on when to use each(link here)"
peerreynders
peerreynders2mo ago
In many ways <Index> is only an optimization when you are rendering primitive values-in which case the <Index> will simply move the text nodes around while leaving DOM elements that are part of the list in place. <For> on the other hand tracks its items by object reference (provided they are objects) and will move the entire DOM partial rendered from that item around to match its position as it changes. So in the vast majority of cases <For> is the correct choice while it will work where <Index> is the more optimal choice.
QWu4xYV
QWu4xYV2mo ago
Follow-up question (also happy to just make it a separate question if that would be better): What about if I need to do some transformation on the store data before displaying it, and still want minimal DOM updates? For example, in https://stackblitz.com/edit/solidjs-templates-inkran?file=src%2FApp.tsx , I aggregate over each of the keys. With a createMemo and some use of prev, I can get just a single row to update, but would ideally just update a single cell. (My understanding is that in this case, because createMemo returns a Signal (not a Store), there isn't a good way to listen for nested values, but I'm not sure about that...) If I have my createMemo update prev in place, it seems that For/Index doesn't know that the update has happened (which makes sense), but if I create a new object for that row, even if a value hasn't changed for that index, it still updates anyway (in this case, the label "B"). What am I still missing here?
QWu4xYV
StackBlitz
Nested For with Memo - StackBlitz
Vite + solid templates
peerreynders
peerreynders2mo ago
Remember this?
To address any followup problems you'll probably need to restructure your data.
The discussion that ensued in the other thread is testament to this. If you are looking for fine-grained but selective reactivity, the way you structure your data will have to reflect your particular, reactive needs. That said for the particular problem that you posited here I submit this customised approach for consideration. FYI: the Comma operator (,)
MDN Web Docs
Comma operator (,) - JavaScript | MDN
The comma (,) operator evaluates each of its operands (from left to right) and returns the value of the last operand. This is commonly used to provide multiple updaters to a for loop's afterthought.
peerreynders
peerreynders2mo ago
QWu4xYV
QWu4xYV2mo ago
@peerreynders , thanks so much for the detailed response and example! Your example solution brought up a couple questions: 1. What is the reason for changing props.data to be an accessor instead of an array? I tested your solution without that change and it seemed to function the exact same, but maybe I'm missing something... 2. I noticed that the val() in <td>{val()}</td> will be an accessor (to be a primitive it would need to be val()()). After some more testing, I realized that Solid will automatically resolve Signals in JSX tags, even though TypeScript will complain about it. I didn't see that in the docs (maybe it's there an I missed it); is it something we should be able to rely on (and if so, I'd be curious why TypeScript complains about it), or should we be checking if val() is a function and handling that?
peerreynders
peerreynders2mo ago
to be an accessor.
Because I perceived a misalignment between
<Table name="storeData" data={storeData} />
<Table name="storeData" data={storeData} />
and
<Table name="memoized" data={memoizedStore()} />
<Table name="memoized" data={memoizedStore()} />
In the first case you are passing a proxy-which isn't "the thing itself" but simply a means "to get at the thing later". In the second case you are forcing "the thing to be created right then and there". By thunking access to "the thing" I'm effectively unifying the interface accepted via the Table's data prop; "give me something that I can run, that will give me the data you want me to show". Now it doesn't matter whether the thunk returns data that was previously reified or that the data is only created on demand (and potentially memoized for later invocations).
I realized that Solid will automatically resolve Signals in JSX tags
It's even simpler than that and you observed correctly that I exploited Solid's idiosyncratic behaviour. Where Solid expects an accessor, it will recursively keep executing returned functions assuming that those functions are accessors themselves. The trade-off is that you can't return bare functions via signal accessors, you have to wrap a function in an object. I've used the wrapper accessor idiom(?) once where the consumers came into existence before the producer was available. The consumers could immediately consume a primary accessor when they were created. Once the producer was created and ready, the primary accessor would emit a wrapped secondary accessor that the consumers could then finally use to subscribe to the producer's actual reactive state.
Dakotys
Dakotys2mo ago
@QWu4xYV Here is a somewhat explanation for your questions: https://discord.com/channels/722131463138705510/1212771462084042793/1212783309957111829 And regarding the code you submitted to other thread. You are needlessly recreating new stores and signals, peerreynders code shows a better way to calculate new values without creating new structures, and also implements recalculation per row instead of whole store on each update. But there are still some unnecessary updates, even though each row (function toTotalMemo()) is wrapped in separate createMemo, the final aggregate isn't which will cause it to needlessly recalculate row value each time aggregate array is used (at least 2 times, once in <thead> and once in <tbody>, on each update ) , the solution would be to wrap aggregate() in createMemo. That would achieve the most optimal number of recalculations and dom updates. But that would create a lot of nested createMemos and there would be a lot of unnecessary comparisons, that would be trying to ensure smallest number of updates. Here is my try on the problem, it abuses that accessors are called recursively but it also achieves the most optimal solution:
Dakotys
Dakotys2mo ago
peerreynders
peerreynders2mo ago
the final aggregate isn't
Which was a deliberate choice. Note that in your version you are running the aggregate accessor in the prop—I'm passing it as an accessor for the component to run. Granted I somehow missed that the prop was being run twice in Table, so in the short term I probably would have memo-ed it despite my distaste for nesting memos. I have since discovered that this works:
const Table = (props: {
name: string;
data: () => Array<{ [key: string]: any }>;
}) => {
return (
<table>
{((data) => (
<>
<thead>
<For each={Object.keys(data[0])}>{(key) => <td>{key}</td>}</For>
</thead>
<tbody>
<For each={data}>
{(row) => (
<tr>
<For each={Object.values(row)}>
{(val) => <td>{val()}</td>}
</For>
</tr>
)}
</For>
</tbody>
</>
))(props.data())}
</table>
);
};
const Table = (props: {
name: string;
data: () => Array<{ [key: string]: any }>;
}) => {
return (
<table>
{((data) => (
<>
<thead>
<For each={Object.keys(data[0])}>{(key) => <td>{key}</td>}</For>
</thead>
<tbody>
<For each={data}>
{(row) => (
<tr>
<For each={Object.values(row)}>
{(val) => <td>{val()}</td>}
</For>
</tr>
)}
</For>
</tbody>
</>
))(props.data())}
</table>
);
};
which obviates the need for the wrapper memo as aggregate is now truly only run in one place. Though it demonstrates that I really need to understand DOM expressions better.
Solid Playground
Quickly discover what the solid compiler will generate from your JSX template
GitHub
GitHub - ryansolid/dom-expressions: A Fine-Grained Runtime for Perf...
A Fine-Grained Runtime for Performant DOM Rendering - ryansolid/dom-expressions
peerreynders
peerreynders2mo ago
Here is my try on the problem,
The issue with
type Key = keyof (typeof storeData)[number];
type AggregatedType = Array<{ label: string; total: any }>;

const calculateRow = (key: Key) =>
storeData.reduce((acc, row) => acc + row[key], 0);

const aggregate = createMemo(() =>
(Object.keys(storeData[0]) as Array<Key>).reduce(
(acc, key) => (
acc.push({
label: key.toUpperCase(),
total: () => calculateRow(key),
}),
acc
),
[] as AggregatedType
)
);
// if aggregate is used only in one table, as only one jsx
// entry and also it is not used in any other function or effect,
// it doesn't even need to be memoized, but we are using it twice,
// in table component so i will wrap it.
type Key = keyof (typeof storeData)[number];
type AggregatedType = Array<{ label: string; total: any }>;

const calculateRow = (key: Key) =>
storeData.reduce((acc, row) => acc + row[key], 0);

const aggregate = createMemo(() =>
(Object.keys(storeData[0]) as Array<Key>).reduce(
(acc, key) => (
acc.push({
label: key.toUpperCase(),
total: () => calculateRow(key),
}),
acc
),
[] as AggregatedType
)
);
// if aggregate is used only in one table, as only one jsx
// entry and also it is not used in any other function or effect,
// it doesn't even need to be memoized, but we are using it twice,
// in table component so i will wrap it.
is that the single memo is subscribed to every detail value in the store - so it will always recalculate every aggregate value, even those that haven't experienced a change in the detail values they depend on. Now, being cheap derived data it doesn't really matter but the overhead of recalculating aggregate values that don't need recalculating is real.
there would be a lot of unnecessary comparisons
That's the React mindset where there is the danger of memoizing every trivial derivation where the cost of the comparing the inbound dependencies could outweigh the cost of the derivation itself. In Solid you use derived values for trivial derivations. It's only when you need referential stability or a costly derivation you reach for memos. There are no comparisons to decide whether a memo needs to be run. It only runs when at least one of its dependencies changes. The comparison only happens once the memo has finished running to decide whether or not there is a change that needs to be propagated. So the job of the nested memos here is guard against re-deriving the aggregates when their dependencies don't change. With this particular code (and a 3x3 matrix) the nested memo could be less efficient despite saving redundant work. Only you know if 10^4 x 10^4 matrices are a real and common possibility and whether the aggregation will always be as cheap as a sum.
Dakotys
Dakotys2mo ago
Since total: () => calculateRow(key), is accessor it is not calculating anything, essentially my aggregate is just returning list of objects with accessors, that made me realize it could have been rewritten into just :
type Key = keyof (typeof storeData)[number];
type AggregatedType = Array<{ label: string; total: any }>;

const calculateRow = (key: Key) =>
storeData.reduce((acc, row) => acc + row[key], 0);

// just array, not function
const aggregate = (Object.keys(storeData[0]) as Array<Key>).reduce(
(acc, key) => (
acc.push({
label: key.toUpperCase(),
total: () => calculateRow(key),
}),
acc
),
[] as AggregatedType
)
type Key = keyof (typeof storeData)[number];
type AggregatedType = Array<{ label: string; total: any }>;

const calculateRow = (key: Key) =>
storeData.reduce((acc, row) => acc + row[key], 0);

// just array, not function
const aggregate = (Object.keys(storeData[0]) as Array<Key>).reduce(
(acc, key) => (
acc.push({
label: key.toUpperCase(),
total: () => calculateRow(key),
}),
acc
),
[] as AggregatedType
)
and since you have also rewritten your toTotalMemo to return accessor instead of memo, when you drop the createMemo our code is basically the same. XD. You are just using normal loops and I am using reduce.
peerreynders
peerreynders2mo ago
total: () => calculateRow(key),
The total accessor is referentially volatile (as are the array and its elements), with every memo execution so the JSX is compelled to run it, in order to determine that nothing has changed.
You are just using normal loops and I am using reduce.
Because I'm a bit paranoid about the number of member accesses/indirections (and potentially spurious subscriptions) proxies can generate.
Dakotys
Dakotys2mo ago
If i change it to
const calculateRow = (key: Key) => (
console.log('row', key), storeData.reduce((acc, row) => acc + row[key], 0)
);
const calculateRow = (key: Key) => (
console.log('row', key), storeData.reduce((acc, row) => acc + row[key], 0)
);
I see only one console.log per update, am I missing something?
Dakotys
Dakotys2mo ago
If you tried to reuse your aggregate function in another table or effect, accessor and its memo would be still recalculated per aggregate use, since it is a function returning new array with own memos, that is why I think it is better to generate array of accessors once, or if we expect the store to expand by new keys wrap it in another memo. https://playground.solidjs.com/anonymous/aa8eb329-2865-4007-9073-05f058e6016a
Solid Playground
Quickly discover what the solid compiler will generate from your JSX template
peerreynders
peerreynders2mo ago
am I missing something?
You know what you're seeing. I just need to understand where my reasoning falls apart.
recalculated per aggregate use
That was a given. If you can't guarantee that it is only run it once you need to memo it. The thing about props, evaluating them too early can create issues, most foremost if they contain JSX.
Discord
Discord - A New Way to Chat with Friends & Communities
Discord is the easiest way to communicate over voice, video, and text. Chat, hang out, and stay close with your friends and communities.
peerreynders
peerreynders2mo ago
OK, that's subtle: - There is only one createMemo. - There are (in this case) four distinct instances of subscribing functions - 1 x outer - 3 x inner
const aggregate = createMemo(function outer() {
return (Object.keys(storeData[0]) as Array<Key>).reduce(
(acc, key) => (
acc.push({
label: key.toUpperCase(),
total: function inner() {
return storeData.reduce((acc, row) => acc + row[key], 0);
},
}),
acc
),
[] as AggregatedType
);
});
const aggregate = createMemo(function outer() {
return (Object.keys(storeData[0]) as Array<Key>).reduce(
(acc, key) => (
acc.push({
label: key.toUpperCase(),
total: function inner() {
return storeData.reduce((acc, row) => acc + row[key], 0);
},
}),
acc
),
[] as AggregatedType
);
});
1. createMemo runs outer(). It subscribes to storeData[0] by accessing it and that's its only dependency. It also creates three separate instances of inner; one for each key. But they are not run. 2. Each inner instance is run later inside the Table's JSX as val (which can be either row().label or row().total evaluated to row().total() (the result of that particular inner())). Each inner registers as a distinct subscribing function with subscriptions to its particular dependencies. All three inner() are subscribed all possible storeData[i]. But each inner() is only subscribed to its particular set of storeData[i][key]s. So when storeData[1]['b'] changes only that particular inner() (subscribed to all storeData[i]['b']) re-runs.