S
SolidJS13mo ago
oneiro

Confused about resource mutation with `createDeepSignal` and `produce`

Hey folks, I am trying to create a resource which gets updated on a button click, by fetching content from a server. However the user may change parts of the data they have already fetched. When refetching the data, we want to make sure that all user changes are not being overwritten. To allow changing small parts of the data and still being reactive, I use createDeepSignal from solid-primitives. My resource is initiated like this:
export const [projectsFromStore, { refetch: reloadProjects, mutate: mutateProjects }] = createResource(loadProjects, {
storage: createDeepSignal,
})
export const [projectsFromStore, { refetch: reloadProjects, mutate: mutateProjects }] = createResource(loadProjects, {
storage: createDeepSignal,
})
Store here is a persisted file. By default this file is empty until the user fetches projects from a remote server for the first time. This fetch function can be used repeatedly and should alter its behaviour depending on if the user already has fetched projects or not as described above. My fetch function looks like this: (continuation in comment)
11 Replies
oneiro
oneiro13mo ago
export async function fetchProjects(): Promise<void> {
const apiUrl = credentialsFromStore()?.url
const apiKey = credentialsFromStore()?.key

try {
if (!apiUrl || !apiKey) {
throw new Error('Could not find credentials')
}
const response = await fetch(apiUrl, buildFetchConfig(apiKey, 'projects'))
const acProjects = z.array(ActiveCollabProjectSchema).parse(response.data)

const syncedProjectsState = projectStateFromAcResponse(acProjects)

const updatedProjects = mutateProjects(
produce((state) => {
if (!state) {
state = syncedProjectsState
return
}

// Make sure already existing user configurations are not being overwritten
// by a re-sync.
for (const syncedProject of Object.values(syncedProjectsState.byId)) {
const maybeExistingProject = state.byId[syncedProject.id]
state.byId[syncedProject.id] = {
...syncedProject,
...(maybeExistingProject ? maybeExistingProject : {}),
}
}
})
)

if (updatedProjects) {
await persistProjects(updatedProjects)
}
} catch (err) {
// TODO:
// add some kind of error handling to the ui, to inform users about the issue
error(JSON.stringify(err, null, 2))
}
}
export async function fetchProjects(): Promise<void> {
const apiUrl = credentialsFromStore()?.url
const apiKey = credentialsFromStore()?.key

try {
if (!apiUrl || !apiKey) {
throw new Error('Could not find credentials')
}
const response = await fetch(apiUrl, buildFetchConfig(apiKey, 'projects'))
const acProjects = z.array(ActiveCollabProjectSchema).parse(response.data)

const syncedProjectsState = projectStateFromAcResponse(acProjects)

const updatedProjects = mutateProjects(
produce((state) => {
if (!state) {
state = syncedProjectsState
return
}

// Make sure already existing user configurations are not being overwritten
// by a re-sync.
for (const syncedProject of Object.values(syncedProjectsState.byId)) {
const maybeExistingProject = state.byId[syncedProject.id]
state.byId[syncedProject.id] = {
...syncedProject,
...(maybeExistingProject ? maybeExistingProject : {}),
}
}
})
)

if (updatedProjects) {
await persistProjects(updatedProjects)
}
} catch (err) {
// TODO:
// add some kind of error handling to the ui, to inform users about the issue
error(JSON.stringify(err, null, 2))
}
}
However this does not work unless I remove the call to produce inside the call to mutate. I don't quite understand why that is. I also have other actions where I toggle a property of a single project (without fetching anything) where I strictly need produce to achieve reactivity. For example:
export async function toggleProjectAvailability(projectID: ProjectId) {
const projects = projectsFromStore()

if (!projects) {
return
}

const updatedProjects = mutateProjects(
produce((state) => {
if (!state) {
return
}

const oldValue = state?.byId[projectID].isAvailableForSelection
state.byId[projectID].isAvailableForSelection = !oldValue
})
)

if (updatedProjects) {
await persistProjects(updatedProjects)
}
}
export async function toggleProjectAvailability(projectID: ProjectId) {
const projects = projectsFromStore()

if (!projects) {
return
}

const updatedProjects = mutateProjects(
produce((state) => {
if (!state) {
return
}

const oldValue = state?.byId[projectID].isAvailableForSelection
state.byId[projectID].isAvailableForSelection = !oldValue
})
)

if (updatedProjects) {
await persistProjects(updatedProjects)
}
}
Can anyone explain to me, why these two are different and what I am missing here? Thanks in advance
bigmistqke 🌈
bigmistqke 🌈13mo ago
my two cents: produce works like in immer. the state you receive in the callback is going to be a proxy that registers all the mutations of that object in the callback, and then set its inner state according to those mutations. state = syncedProjectsState will not work for that reason, because with a proxy you can only listen to changes on a property of the proxy (one of proxy's biggest gotchas). state = syncedProjectsState will work with simple mutation, since you are directly setting the reference.
oneiro
oneiro13mo ago
Ah yeah that might be the issue at play here. Hm, I'll have a deeper look at this. A few hours ago I tried to create a minimal example and play around with it, but the result wasn't reactive at all. I assume I must have messed up there as well. Anyway, thanks for your answer 🙂 hm, while what you wrote is obviously true, I still can't get a working example at all. I think the missing part is overwriting the deepSignal at the top level. What would be the correct way to override the complete state here? (in other words, how can I not just update a property of my resource, but the whole resource itself? Simplified example:
function MyComponent() {
const [data, { mutate }] = createResource(() => Promise.resolve({ a: 'some data', b: 'the other value' }), {
storage: createDeepSignal,
})

createEffect(() => {
if (data.loading) {
console.log('Data is loading')
return
}

console.log('Current state: ', data())
})

const doIt = () => {
// This change is reflected as expected
mutate(
produce((pre) => {
console.log({ pre })

if (pre) {
pre.a = 'new state'
}
})
)

// This change however is not and produce would also not help here
mutate({ a: 'changed again', b: 'and also this' })
}
}
function MyComponent() {
const [data, { mutate }] = createResource(() => Promise.resolve({ a: 'some data', b: 'the other value' }), {
storage: createDeepSignal,
})

createEffect(() => {
if (data.loading) {
console.log('Data is loading')
return
}

console.log('Current state: ', data())
})

const doIt = () => {
// This change is reflected as expected
mutate(
produce((pre) => {
console.log({ pre })

if (pre) {
pre.a = 'new state'
}
})
)

// This change however is not and produce would also not help here
mutate({ a: 'changed again', b: 'and also this' })
}
}
oh, wait, this mutate((_) => ({ a: 'overwrite', b: 'overwrite' }) seems to work, which is exactly what @bigmistqke was writing, right? I must've messed up somewhere before...
thetarnav
thetarnav13mo ago
createDeepSignal is pretty much this:
thetarnav
thetarnav13mo ago
so as you can see it's already using reconcile, which cannot be just combines with produce
oneiro
oneiro13mo ago
hm, so I shouldn't use produce at all with deepSignals?
thetarnav
thetarnav13mo ago
to have a normal store settter for mutate and still reconcile on refetch you have to wire the setter a bit differently
oneiro
oneiro13mo ago
hm, man this is pretty hard to wrap my head around. I just started solid coming from years of react 😄
thetarnav
thetarnav13mo ago
like the problem are: 1. types - mutate will alwyas have a Setter type, so it needs to be typecaseted to StoreSetter I think 2. how do you determine if the setter was called with mutate or by refetching? stores add a layer of abstraction so it's hard to be able to handle custom cases if you don't know the basics
oneiro
oneiro13mo ago
yeah. So maybe my approach isn't the best in general. I'll try to rephrase, what I want to achieve: - I have a list of projects - initally, when the app is started for the first time, the user does not yet have that list - instead they have to fetch it from a server - this list of projects is then persisted to a file - the next time the user starts the app, the list will be fetched from said file (this is the actual resource) - however this list might update on the server so the user can push a button to refetch the list - the user can also adjust settings on items of this list, so when they refetch we want to avoid to override these custom changes The more I think about it, this architecture might be bad anyway. I might make sense to normalize this a bit more and save these settings separate from the list of projects. We would then not have to worry about the case of overriding local project settings and could focus on the case of overriding the whole store. Thanks btw., really appreciate you folks taking the time to help out! ♥️ This seems to work, right now (but I am questioning this architecture in general):
export async function fetchProjects(): Promise<ProjectsState | null | undefined> {
const apiUrl = credentialsFromStore()?.url
const apiKey = credentialsFromStore()?.key

try {
if (!apiUrl || !apiKey) {
throw new Error('Could not find credentials')
}
const response = await fetch(apiUrl, buildFetchConfig(apiKey, 'projects'))
const acProjects = z.array(ActiveCollabProjectSchema).parse(response.data)

const syncedProjectsState = projectStateFromAcResponse(acProjects)

const projectsFS = projectsFromStore()

console.log({ projectsFS, syncedProjectsState })

let updatedProjects

if (!projectsFS) {
console.log('Called full update')
updatedProjects = mutateProjects(() => syncedProjectsState)
} else {
updatedProjects = mutateProjects(
produce((state) => {
if (!state) {
return
}

// Make sure already existing user configurations are not being overwritten
// by a re-sync.
for (const syncedProject of Object.values(syncedProjectsState.byId)) {
const maybeExistingProject = state.byId[syncedProject.id]
state.byId[syncedProject.id] = {
...syncedProject,
...(maybeExistingProject ? maybeExistingProject : {}),
}
}
})
)
}

console.log({ projectsFS, updatedProjects })

if (updatedProjects) {
console.log('called persist')
await persistProjects(updatedProjects)
}

return updatedProjects
} catch (err) {
// TODO
}
}
export async function fetchProjects(): Promise<ProjectsState | null | undefined> {
const apiUrl = credentialsFromStore()?.url
const apiKey = credentialsFromStore()?.key

try {
if (!apiUrl || !apiKey) {
throw new Error('Could not find credentials')
}
const response = await fetch(apiUrl, buildFetchConfig(apiKey, 'projects'))
const acProjects = z.array(ActiveCollabProjectSchema).parse(response.data)

const syncedProjectsState = projectStateFromAcResponse(acProjects)

const projectsFS = projectsFromStore()

console.log({ projectsFS, syncedProjectsState })

let updatedProjects

if (!projectsFS) {
console.log('Called full update')
updatedProjects = mutateProjects(() => syncedProjectsState)
} else {
updatedProjects = mutateProjects(
produce((state) => {
if (!state) {
return
}

// Make sure already existing user configurations are not being overwritten
// by a re-sync.
for (const syncedProject of Object.values(syncedProjectsState.byId)) {
const maybeExistingProject = state.byId[syncedProject.id]
state.byId[syncedProject.id] = {
...syncedProject,
...(maybeExistingProject ? maybeExistingProject : {}),
}
}
})
)
}

console.log({ projectsFS, updatedProjects })

if (updatedProjects) {
console.log('called persist')
await persistProjects(updatedProjects)
}

return updatedProjects
} catch (err) {
// TODO
}
}
thetarnav
thetarnav13mo ago
something like this might help not sure if it works exackly and probably could be improved
function createDeepSignal<T>() {
const [store, setStore] = createStore({ value: null as any as T });

const mutate: SetStoreFunction<T> = (...args: any[]) =>
setStore("value", ...args);

return {
storage(value?: T): Signal<T | undefined> {
setStore({ value });
return [
() => store.value,
(v: T) => {
const unwrapped = unwrap(store.value);
typeof v === "function" && (v = v(unwrapped));
setStore("value", reconcile(v));
return store.value;
},
] as any;
},
mutate,
};
}

type User = { name: string; height: string };

const fetchUser = async (id: number) =>
fetch(`https://swapi.dev/api/people/${id}/`).then(
(r) => r.json() as Promise<User>
);

const { storage, mutate } = createDeepSignal<User>();
const [user] = createResource(userId, fetchUser, { storage });
function createDeepSignal<T>() {
const [store, setStore] = createStore({ value: null as any as T });

const mutate: SetStoreFunction<T> = (...args: any[]) =>
setStore("value", ...args);

return {
storage(value?: T): Signal<T | undefined> {
setStore({ value });
return [
() => store.value,
(v: T) => {
const unwrapped = unwrap(store.value);
typeof v === "function" && (v = v(unwrapped));
setStore("value", reconcile(v));
return store.value;
},
] as any;
},
mutate,
};
}

type User = { name: string; height: string };

const fetchUser = async (id: number) =>
fetch(`https://swapi.dev/api/people/${id}/`).then(
(r) => r.json() as Promise<User>
);

const { storage, mutate } = createDeepSignal<User>();
const [user] = createResource(userId, fetchUser, { storage });
like you could create the store first, pass the reconciling store setter to the resource, and use normal setter in your app