Unexpected Actions dependency cross-contamination

Hello! I found some problems with actions and I would like to discuss them. This may be an issue from my part, but due to the time I spent on these problems I'm very confident this has something weird. It's about how actions' dependencies cross-contaminate other actions' result values. I want to highlight 2 noticeable problems. Here, I made the an MRE some submissions and targeted effects:
const fetchedText = useSubmission(fetchText);
const fetchedNumber = useSubmission(fetchNumber);
const [messages, setMessages] = createSignal<string[]>([]);

createEffect(on(() => fetchedText.result, () => {
console.log("text fetched!", fetchedText.result);
setMessages(prev => [...prev, "text fetched! " + Date.now()])
}))

createEffect(on(() => fetchedNumber.error, () => {
console.log("number error!", fetchedNumber.error);
setMessages(prev => [...prev, "number error! " + Date.now()])
}))
const fetchedText = useSubmission(fetchText);
const fetchedNumber = useSubmission(fetchNumber);
const [messages, setMessages] = createSignal<string[]>([]);

createEffect(on(() => fetchedText.result, () => {
console.log("text fetched!", fetchedText.result);
setMessages(prev => [...prev, "text fetched! " + Date.now()])
}))

createEffect(on(() => fetchedNumber.error, () => {
console.log("number error!", fetchedNumber.error);
setMessages(prev => [...prev, "number error! " + Date.now()])
}))
I have two buttons, one that triggers the text action, and the other one triggers the number action. Simple enough. The text button completes successfully. The second button errors. Now, regarding the effects, the first one should only care whenever the text action successfully returns. Nope! Not at all. When I click the text button, that effect triggers twice. I figured out that it was because its pending's state changes, and that triggers the effect, which I explicitly stated I don't care about (with on). So, it's like that effect is listening for every fetchedText property; as if I did on(() => fetchedText, () => ... (without .result). But that doesn't work which is expected.
12 Replies
MásQuéÉlite
MásQuéÉliteOP4mo ago
To top it all off, both of these actions are intermingled. When I press the text button, the second effect activates, which is unexpected, and leads to very weird behaviors as you add more actions, and other helpers, like query when fetching. And, when pressing the number button, it doesn't trigger the first effect. So the cross-contamination happens from the text action to the number action, but not vice versa, which is even weirder! I've tried to play with roots, owners, using any primitive I'd think it'd solve the problem, like createSubRoot, but the end result is the same: deeply intermingled dependencies no matter how you twist it. Here's a video that quickly demonstrates what I'm saying, and a sandbox: https://stackblitz.com/edit/solidjs-templates-r4uwy7rs?file=src%2FResults.tsx
Madaxen86
Madaxen864mo ago
You can probably drop all the createEffect and Message signals if you just use useSubmissions (s at the end) instead which collects all submissions of an action. https://docs.solidjs.com/solid-router/reference/data-apis/use-submissions
useSubmissions - Solid Docs
Documentation for SolidJS, the signals-powered UI framework
MásQuéÉlite
MásQuéÉliteOP4mo ago
That is fair. However, I think I didn't get my message across on what my goal was. The message array was just for showcasing the number of submissions that there was happening. My goal is to make something happen whenever the submission fails or succeeds. Like a triggering a sonner. It's also not a matter of just checking whichever state I need, whose workaround would involve checking all five properties of the Submission. That is, if I wanted to check if a submission went right, I would need to check that .pending is undefined, .error is undefined, etc...).. For example, if I wanted to clear the previous toast right after the next successful submission, I would need an unneeded workaround since this effect will also be triggered when .pending changes as well, thus losing the id in the process.
createEffect(on(() => submitState.result,
(_, __, prevToastId: string | number | undefined) => {
if (prevToastId !== undefined) {
toast.dismiss(prevToastId);
}
if (!submitState.pending) {
return toast.success(
"Success! You got: " + submitState.result,
{ duration: Number.MAX_VALUE }
);
}
}
))
createEffect(on(() => submitState.result,
(_, __, prevToastId: string | number | undefined) => {
if (prevToastId !== undefined) {
toast.dismiss(prevToastId);
}
if (!submitState.pending) {
return toast.success(
"Success! You got: " + submitState.result,
{ duration: Number.MAX_VALUE }
);
}
}
))
I was just a bit unsure whether this would be better as an Issue in GitHub, or if I was just exaggerating, so that's why I posted this here.
Madaxen86
Madaxen864mo ago
As far as I know is that submissions are global and every submission is triggered by any action and then useSubmission will filter if there the action that triggered corresponds to the action passed to the "hook". What you are trying to do is better done in the event handler instead of the useSubmission with createEffect. You can also just const res = await fetchText(); setMessages(prev => […prev, res ] There was this talk by David Khourshid (Maintainer of XState) about React's useEffects in which he talks about "action effect" and that they do belong inside event handlers https://youtu.be/bGzanfKVFeU?feature=shared&t=645
BeJS
YouTube
Goodbye, useEffect - David Khourshid
From fetching data to fighting with imperative APIs, side effects are one of the biggest sources of frustration in web app development. And let’s be honest, putting everything in useEffect hooks doesn’t help much. Thankfully, there is a science (well, math) to side effects, formalized in state machines and statecharts, that can help us visua...
Madaxen86
Madaxen864mo ago
e.g. you could
function App() {
const textAction = useAction(fetchText);
const numberAction = useAction(fetchNumber);
const [span, setSpan] = createSignal<HTMLSpanElement | null>(null);
const [input, setInput] = createSignal<HTMLInputElement | null>(null);

const [messages, setMessages] = createSignal<Array<string | number>>([]);

async function handleUpdate<T extends string | number>(
msg: T,
action: (msg: T) => Promise<T>
) {
const res = await action(msg);
setMessages((prev) => [...prev, res]);
}

return (
<>
<div class="content__input">
<div>{messages()?.join()}</div>
<div>
<span ref={setSpan} contentEditable>
Content...
</span>
<Suspense>
<button
onClick={() => handleUpdate(span()?.textContent + '', textAction)}
>
Send text
</button>
</Suspense>
</div>
<div>
<input type="number" ref={setInput} value="100" />
<Suspense>
<button
onClick={() => handleUpdate(Number(input()?.value), numberAction)}
>
Send number
</button>
</Suspense>
</div>
</div>
</>
);
}
function App() {
const textAction = useAction(fetchText);
const numberAction = useAction(fetchNumber);
const [span, setSpan] = createSignal<HTMLSpanElement | null>(null);
const [input, setInput] = createSignal<HTMLInputElement | null>(null);

const [messages, setMessages] = createSignal<Array<string | number>>([]);

async function handleUpdate<T extends string | number>(
msg: T,
action: (msg: T) => Promise<T>
) {
const res = await action(msg);
setMessages((prev) => [...prev, res]);
}

return (
<>
<div class="content__input">
<div>{messages()?.join()}</div>
<div>
<span ref={setSpan} contentEditable>
Content...
</span>
<Suspense>
<button
onClick={() => handleUpdate(span()?.textContent + '', textAction)}
>
Send text
</button>
</Suspense>
</div>
<div>
<input type="number" ref={setInput} value="100" />
<Suspense>
<button
onClick={() => handleUpdate(Number(input()?.value), numberAction)}
>
Send number
</button>
</Suspense>
</div>
</div>
</>
);
}
MásQuéÉlite
MásQuéÉliteOP4mo ago
I see. Now it makes sense That's a bit annoying since I expected these actions to work where an action result is targeted to a specific submission hook (just like from where this feature was inspired from) For instance, another example that I forgot to show that happened to me recently is this one:
// NameList.tsx
const namesAction = useAction(fetchNames);
const submitCreate = useSubmission(createEmployee);

createEffect(on(() => submitCreate.result, () => {
namesAction();
}, { defer: false }))
// NameList.tsx
const namesAction = useAction(fetchNames);
const submitCreate = useSubmission(createEmployee);

createEffect(on(() => submitCreate.result, () => {
namesAction();
}, { defer: false }))
My intention would be to fetch the list of all names, and whenever an employee was created, listen to that specific submission and then refetch the list name Of course, this comes as bit of a lazy way to do it, since I can just get back the name of the new employee, insert it into the list, and revalidate the name cache (maybe). But regardless, it's just another simple example to illustrate more my point Your solutions are fair (and very appreciated btw!), since what you're doing is to just await the action directly without going through the process of using useSubmission, or just straight-up fetch the data in the component itself So basically the workaround is to not use useSubmission (or even useAction) at all, since their behaviors are quite not-so-intuitive The key point here is that submissions become pointless if I can trigger any submission via any action. So really it doesn't matter which action you put in useSubmission, since it'll trigger all submissions internally, obscurely
const actionOne = useAction(fetchOne);
const actionTwo = useAction(fetchTwo);
const actionThree = useAction(fetchThree);
const submitOne = useSubmission(fetchOne);

createEffect(on(() => submitOne.result, () => {
console.log("Effect executed! That means actionOne has just been triggered, right...?")
}))
const actionOne = useAction(fetchOne);
const actionTwo = useAction(fetchTwo);
const actionThree = useAction(fetchThree);
const submitOne = useSubmission(fetchOne);

createEffect(on(() => submitOne.result, () => {
console.log("Effect executed! That means actionOne has just been triggered, right...?")
}))
So actions become mere fetchers/fetch wrappers since there's not much to them other than that. Therefore this feature becomes redundant I certainly believe this feature's intention is to have a convenient way to work reactively with async operations in a controlled/localized way, just like createResource and createAsync So, at least from my point of view, it would be natural to chuck in an effect listening to the submission hook, just like createAsync also needs a reactive dependency in order to work properly, which has an isolated scope (as it should) (if I didn't use this convenient feature, I would just create a handler without needing an effect as you shared) But again, this is just what I think; I just want to make sure that if what I think is on the right track, or if I'm missing some fundamental concepts/philosophies, which is making me understand this the wrong way, since I don't have an actual really deep insight on this Although, either way, I'm thinking on making a primitive by trying to play with actions and submissions to make these work exactly as I expect But due to how they work fundamentally, I might have to reimplement these from scratch, which is a completely different story (that might not be worth looking at)
Madaxen86
Madaxen864mo ago
Actions are for data mutations and to revalidate queries. query action go hand in hand. You can leverage single flight mutations. I made a video about that: https://youtu.be/s9G_64eHrfY?si=t3mXBqtbVAQ2h1y- with useSubmission you can also show optimistic updates. Actions are made to work without JavaScript in the browser (progressive enhancement)
Martin Rapp
YouTube
Actions in SolidStart - How to leverage Single-Flight-Mutations
In this video we'll explore ACTIONS im SolidStart. You will see how actions invalidate the query cache, how you can pinpoint which cached data should be revalidated an how you can use useSubmission to disable form elements during submission of data. We will finally explore how we can leverage Single-Flight-Mutations to speed up your app. Github...
MásQuéÉlite
MásQuéÉliteOP4mo ago
I see And I just watched your video. Good explanation honestly I think I should've said that my app is a SPA and it won't use the metaframework I am aware that, in that case, actions are of not much use then, but they honestly have helped a lot in some things (even the docs say that they are fine to use on client-side things) But yeah, I'm using them with query and all It's just a bit annoying the fact that the state is global. But I won't complain about its design if avoiding this problem wasn't on the list, but doing the things action was created for (as you point out). Although I still feel a bit uneasy about this I might write some primitive hacks to make this work as I want, or I might not since it would require an understanding of topics I can't wrap my head around (yet). But yeah, I don't know I'll also admit that I'm also a bit of a "use that shiny tool" person (like considering using Modular Forms). That's curiosity for you, I guess But at least my thoughts are more on track on what I should expect using these features thanks to this thread. So, thank you!
MásQuéÉlite
MásQuéÉliteOP4mo ago
also, is it me, or have I started a trend? I hope the "Possible Bug" tag doesn't get overused. I used it with the best of my intentions, with the current knowledge I have
No description
MásQuéÉlite
MásQuéÉliteOP4mo ago
oh well Okay, at the end I managed to enclose the actions within independent reactive scopes with an undocumented property actions have, and by also providing context to them individually (via owner injection) I could create the action definition inside each component, but that's pretty much a no-go from an organization perspective Also, I had a question out of curiosity... @Madaxen86 Do you know how actions' strings are useful or how can I use them? (i.e. action(fn: () => Promise<...>, name?: string): the second argument) I know query uses them for cache purposes (giving each an id), but what about actions? In the docs it says that they are used for debug purposes, but I've never seen them, so I usually don't bother to fill out the second argument I also saw in your video that you had all of your actions with that string argument filled
Madaxen86
Madaxen864mo ago
Afaik They’re relevant for SSR. I think they are used under the hood for hydration and probably for single fight mutations. But I don’t know for sure. Maybe you could use a factory function. Don’t know if it would help like ts export const initAction = () => action(async () => …,"mutateUser") And just call that inside your component!?
MásQuéÉlite
MásQuéÉliteOP4mo ago
Oh, I see. Thanks! Yeah, that's the solution basically I just have to make sure that I don't lose the context if I wanna interact with anything about the component when the action completes

Did you find this page helpful?