T
TanStack3w ago
quaint-moccasin

Collection errors are not trackable without calling useLiveQuery

If I check collection.utils.errorCount() in a component that won't use a live query hook, the collections error utils won't trigger a rerender. It's a bummer because I'd like to be able to check if a collection has errored in a child component, and I don't want to subscribe to a live query in the parent as it adds 10-15ms to the render duration of the parent component. This tiny delay makes the children optimistic updates feel much less snappy
5 Replies
sunny-green
sunny-green3w ago
How would you imagine this work? How are you measuring 10-15ms? Our benchmarks show live queries taking < 1ms unless you have something like 100k+ objects
quaint-moccasin
quaint-moccasinOP3w ago
I was thinking about a react hook that subscribes to the collection's status without querying it, idk if that makes any sense let me share the code, I might be doing something wrong that causes the delay to give you a bit of context on the software, it allows its users to build forms from building blocks. Each building block becomes a field that can receive answers from multiple members of a Mission. The form itself is called a Step: -> Missions have Steps, -> Steps have Fields -> Fields have Answers I want each "answers" collection to be related to a mission and a step. So this is how I create the answers collection:
export const createStepAnswersCollection = (
missionId: string,
stepId: string
) =>
createCollection(
queryCollectionOptions({
queryKey: orpc.missions.steps.answers.list.queryKey({
input: { missionId, stepId },
}),
queryFn: () =>
orpc.missions.steps.answers.list.call({
missionId,
stepId,
}),
queryClient,
getKey: (item) => createStepAnswerKey(item),
onUpdate: async ({ transaction }) => {
const {
modified: { answer, status },
original,
} = transaction.mutations[0];

const { missionId, fieldId } = original;

if (original.answer.type !== answer.type)
throw new Error("Answer type mismatch");

await orpc.missions.steps.answers.update.call({
missionId,
fieldId,
answer,
status,
});
},
onInsert: async ({ transaction }) => {
const { modified } = transaction.mutations[0];
await orpc.missions.steps.answers.update.call(modified);
},
})
);
export const createStepAnswersCollection = (
missionId: string,
stepId: string
) =>
createCollection(
queryCollectionOptions({
queryKey: orpc.missions.steps.answers.list.queryKey({
input: { missionId, stepId },
}),
queryFn: () =>
orpc.missions.steps.answers.list.call({
missionId,
stepId,
}),
queryClient,
getKey: (item) => createStepAnswerKey(item),
onUpdate: async ({ transaction }) => {
const {
modified: { answer, status },
original,
} = transaction.mutations[0];

const { missionId, fieldId } = original;

if (original.answer.type !== answer.type)
throw new Error("Answer type mismatch");

await orpc.missions.steps.answers.update.call({
missionId,
fieldId,
answer,
status,
});
},
onInsert: async ({ transaction }) => {
const { modified } = transaction.mutations[0];
await orpc.missions.steps.answers.update.call(modified);
},
})
);
You can access the answers collections from a Zustand store that make available a get method to either initialize the collection in cache, or get the collection from cache:
type StepAnswersCollection = ReturnType<typeof createStepAnswersCollection>;

type StepAnswersStore = {
answers: Map<string, StepAnswersCollection>;
get: (missionId: string, stepId: string) => StepAnswersCollection;
};

const createKey = (missionId: string, stepId: string) =>
`${missionId}-${stepId}`;

export const useStepAnswersCollectionsStore = create<StepAnswersStore>()(
(set, get) => ({
answers: new Map(),
get: (missionId: string, stepId: string) => {
const { answers } = get();
const key = createKey(missionId, stepId);
if (answers.has(key)) {
const answer = answers.get(key);
if (answer) return answer;
}

const collection = createStepAnswersCollection(missionId, stepId);

const newAnswersMap = new Map(answers);
newAnswersMap.set(key, collection);
set({ answers: newAnswersMap });

return collection;
},
})
);
type StepAnswersCollection = ReturnType<typeof createStepAnswersCollection>;

type StepAnswersStore = {
answers: Map<string, StepAnswersCollection>;
get: (missionId: string, stepId: string) => StepAnswersCollection;
};

const createKey = (missionId: string, stepId: string) =>
`${missionId}-${stepId}`;

export const useStepAnswersCollectionsStore = create<StepAnswersStore>()(
(set, get) => ({
answers: new Map(),
get: (missionId: string, stepId: string) => {
const { answers } = get();
const key = createKey(missionId, stepId);
if (answers.has(key)) {
const answer = answers.get(key);
if (answer) return answer;
}

const collection = createStepAnswersCollection(missionId, stepId);

const newAnswersMap = new Map(answers);
newAnswersMap.set(key, collection);
set({ answers: newAnswersMap });

return collection;
},
})
);
Then, I have a component responsible for rendering all the fields from a step:
export const FieldsList = (step: StepDTO) => {
const collection = useStepAnswersCollectionsStore((store) => {
return store.get(step.missionId, step.id);
});

useLiveQuery((query) => query.from({ answers: collection }));

const error = collection.utils.lastError();

const [groupFields, orphanFields] = useMemo(
() => partitionAndParseFields(step.fields ?? []),
[step]
);

if (error) {
const message =
error instanceof Error
? error.message
: "Error fetching step answers";

return <div>Error: {message}</div>;
}

return (
<div ...>
<div ...>
{groupFields.map((group, index) => {
return (
<GroupField
group={group}
key={group.id}
missionId={step.missionId}
/>
);
})}
</div>
<FieldsToc groupFields={groupFields} />
</div>
);
};
export const FieldsList = (step: StepDTO) => {
const collection = useStepAnswersCollectionsStore((store) => {
return store.get(step.missionId, step.id);
});

useLiveQuery((query) => query.from({ answers: collection }));

const error = collection.utils.lastError();

const [groupFields, orphanFields] = useMemo(
() => partitionAndParseFields(step.fields ?? []),
[step]
);

if (error) {
const message =
error instanceof Error
? error.message
: "Error fetching step answers";

return <div>Error: {message}</div>;
}

return (
<div ...>
<div ...>
{groupFields.map((group, index) => {
return (
<GroupField
group={group}
key={group.id}
missionId={step.missionId}
/>
);
})}
</div>
<FieldsToc groupFields={groupFields} />
</div>
);
};
The GroupField ends up rendering a list of its children fields from a component registry, where a live query of the answers collection is consumed with a where clause the filter the answers related to this field only. adding
useLiveQuery((query) => query.from({ answers: collection }));
useLiveQuery((query) => query.from({ answers: collection }));
adds about 15ms of delay I'm measuring the delay by logging performance.now() at the start of the render of every a component in the chain from <FieldsList/> to <Field/> and when I add useLiveQuery in <FieldsList/>, <Field/> is starting to rerender 15ms after <FieldsList/> I may need to check whether something in the chain is causing that long render time, because in that case the delay would come from FieldsList that forces its children to re-render when it subscribes to the live query, unlike when it doesn’t subscribe and the children don’t need to re-render. let me look into this more carefully before taking up any more of your time. well, memoizing GroupField solved the issue... so the live query was definitely not the issue! And I guess it's cheap enough that I can use it without worrying about it much, as long as I don't write trash React code underneath 🙂 while am at it and that you have a bit more context, do you think Zustand brings any value or should I simplify and go with a plain js Map? As the collection is not reactive, I'm thinking of moving the "store" out of Zustand
sunny-green
sunny-green3w ago
Yeah, it seems fine if the data is in Zustand but w/o reactivity, it's not necessary
quaint-moccasin
quaint-moccasinOP3w ago
thanks for confirming, and thank you for your time! DB is a game changer, it's one of those rare easy migrations that delivers awesome features in both user land and DX wise, while reducing the complexity and total LOC I used to roll my own optimistic updates and it was hard for me to believe that it could be solved by a library, but now I'm freaking sold I'll make sure to spread my excitement!
sunny-green
sunny-green3w ago
great to hear!

Did you find this page helpful?