Dexie.js icon indicating copy to clipboard operation
Dexie.js copied to clipboard

Create a library for a reactive Dexie.js query in Svelte 5

Open oliverdowling opened this issue 10 months ago • 11 comments

I put the code from issue #2075 into a Svelte library.

I just used sv create and stripped it down a bit.

oliverdowling avatar Jan 09 '25 02:01 oliverdowling

Thanks! The reason it fails to build seem to be outside of this PR - something has become broken elsewhere.

dfahlander avatar Jan 09 '25 07:01 dfahlander

what about this little supplementary helper function?


export function liveQueryState<T, I>(
	querier: () => T | Promise<T | undefined>,
	dependencies: () => unknown[] = () => [],
	default_state: I
): T | I {
	return $derived(stateQuery(querier, dependencies).current || default_state);
}

with usage:

let friendsQuery = liveQueryState(
  () => db.friends.where('age').between(minAge, maxAge).toArray(),
  () => [minAge, maxAge],
  [{name: 'Jesus' age: -Infinity}]
);

It returns what svelte users would expect from a svelte version of liveQuery.

I added the default_state feature as its easier to optionally replace undefined here than in each svelte component.

You could add a default_state to stateQuery but I don't think its needed and I couldn't work out how to make the typings work when with a default value of undefined.

export function stateQuery<T, I>(
	querier: () => T | Promise<T | undefined>,
	default_state: I,
	dependencies: () => unknown[] = () => []
): { current: T | I } {
	const query = $state<{ current: T | I }>({ current: default_state });
	$effect(() => {
		dependencies?.();
		return liveQuery(querier).subscribe((result: T | undefined) => {
			if (result === undefined) {
				query.current = default_state;
			} else {
				query.current = result as T;
			}
		}).unsubscribe;
	});
	return query;
}

DanConwayDev avatar Jan 09 '25 15:01 DanConwayDev

Part of the issue was that rendered components would flicker very briefly when the dependencies changed. Your example would have the same issue of flickering, but to a default value instead.

I think that a better approach for your use-case would be to not have the check for undefined at all and then use it like this:

const friendsQuery = liveQueryState(
  () => db.friends.where('age').between(minAge, maxAge).toArray(),
  () => [minAge, maxAge]
);
let friends = $derived(friendsQuery.current ?? [{name: 'Jesus', age: -Infinity}]);

Keeping the undefined check would make it an initial-state rather than a default-state.

oliverdowling avatar Jan 09 '25 16:01 oliverdowling

I was also looking at Issue #2089 and considered something along the lines of:

export function stateQuery<T>(querier: () => T | Promise<T>, dependencies?: () => unknown[]) {
	const query = $state<{ result?: T; isLoading: boolean; error?: any }>({
		result: undefined,
		isLoading: true,
		error: undefined,
	});
	$effect(() => {
		dependencies?.();
		return liveQuery(querier).subscribe(
			(result) => {
				query.error = undefined;
				if (result !== undefined) {
					query.result = result;
					query.isLoading = false;
				} else {
					query.isLoading = true;
				}
			},
			(error) => {
				query.error = error;
				query.isLoading = false;
			}
		).unsubscribe;
	});
	return query;
}

And then you could achieve a default state with:

const friendsQuery = stateQuery(
	() => db.friends.where('age').between(minAge, maxAge).toArray(),
	() => [minAge, maxAge]
);
let friends = $derived(
	friendsQuery.isLoading || friendsQuery.result === undefined
		? [{ name: 'Jesus', age: -Infinity }]
		: friendsQuery.result
);

oliverdowling avatar Jan 09 '25 16:01 oliverdowling

Its annoying that it cant be used with a single line but i guess that's down to the constraints of $state and $derived svelte.

but as its can't your isLoading and error properties look useful.

DanConwayDev avatar Jan 09 '25 17:01 DanConwayDev

Is it just an initial state that you're looking for, or for any time it is loading? Svelte's $state function just takes an initial value but I was unsure because of re-assigning the value in liveQuery in your example.

Why is your default state a different type to the query result type? If it is intentionally different, then I think it should be separated into a $derived rune in the component.

I was trying to stick as close to the liveQuery implementation as possible and for my workflows it feels like it doesn't belong, but this might work for you:

export function stateQuery<T>(querier: () => T | Promise<T>, dependencies?: () => unknown[], initialValue?: T) {
	const query = $state<{ value?: T; isLoading: boolean; error?: any }>({
		value: initialValue,
		isLoading: true,
		error: undefined,
	});
...
const friendsQuery = stateQuery(
	() => db.friends.where('age').between(minAge, maxAge).toArray(),
	() => [minAge, maxAge],
	[{ name: 'Jesus', age: -Infinity }]
);

oliverdowling avatar Jan 10 '25 03:01 oliverdowling

Is it just an initial state that you're looking for, or for any time it is loading? Svelte's $state function just takes an initial value but I was unsure because of re-assigning the value in liveQuery in your example.

Why is your default state a different type to the query result type? If it is intentionally different, then I think it should be separated into a $derived rune in the component.

having a different initial and loading type is an edge case and, as you say, it could be implemented with $derived and isLoading.

I see you suggested renaming current to result but I think current communicates more meaning: {current?: T, isLoading: boolean, error: unknown}

I started on this journey because I wanted to upgrade from svelte 4 to svelte 5. I previously was using the pattern:

/// $lib/components/things.ts
...
let search_input = writable('');
$: things = query_centre.searchThings($search_input);
...
/// $lib/query_centre.ts
class QueryCentre {
  searchThings(query: string) {
	/// do stuff to find things and update db...
	return liveQuery(async () => {
	  return await db.repos.where('searchWords')
		.startsWithAnyOfIgnoreCase(query)
		.distinct()
		.toArray();
	});
}
const query_centre = new QueryCentre();
export default query_centre;

but with svelte 5 I wanted to do this:

/// $lib/components/things.ts
...
let search_input = $state('');
let things: T | undefined = $derived(query_centre.searchThings(search_input));
...
/// $lib/ query_centre.ts
class QueryCentre {
  searchThings(query: string) {
	/// do stuff to find things and update db...
	return liveQueryState(
          () => db.repos.where('searchWords').startsWithAnyOfIgnoreCase(query).distinct().toArray(),
          () => [query], // I now realize this is unnecessary 
	});
}
const query_centre = new QueryCentre();
export default query_centre;

but I dont think liveQueryState can be implemented to work like that.

I could use your function as is but having to define things_query and things feels too boilerplatey:

/// $lib/components/things.ts
...
let search_input = $state('');
let things_query = $derived(query_centre.searchThings(search_input));
let things = $derived(things_query.current || []);
...
}
/// $lib/ query_centre.ts
class QueryCentre {
  searchThings(query: string) {
	/// do stuff to find things and update db...
	return liveQueryState(
          () => db.repos.where('searchWords').startsWithAnyOfIgnoreCase(query).distinct().toArray(),
	});
}

or just leave query_centre as was and leave things as an observable. But I don't think this would consider good practice in svelte 5?

/// $lib/components/things.ts
...
let search_input = $state('');
let things = $derived(query_centre.searchThings(search_input));
...
}

Would you say using your function would be the most idiomatic use of svelte 5?

EDIT: I'm still getting the flickering with both these svelte 5 patterns.

DanConwayDev avatar Jan 10 '25 11:01 DanConwayDev

The renaming is accidental. For me, it makes sense to have the "result" of a query and that is what I have in my project code. I see "current" being used by others and in other places, but for me it doesn't quite feel right here. When I issue a new query that has not returned yet, the value is no longer "current" but it is now an "old" value. I think "value" would probably be a good alternative, but I will continue to use "result" in my own project, so I'm happy for this to be named whatever makes the most sense for others. I tend to only use "current" for things like dynamic programming with lists/queues/graphs.

I think the reason that you are still getting flickering is because the $derived function re-runs the searchThings function that creates the query entirely, you're receiving a new state each time rather than updating the state, which is also why the dependencies are unnecessary. Try removing the “$derived” function wrapper in your first Svelte 5 example, I think that’s will be what you are looking for. I’m away and can’t test or give code examples currently.

Since liveQuery returns an observable, it is the actual resulting value from the query, and anything that subscribes to the observable will get updated. However with signals, used in Svelte 5, changes to the value itself will not cross the function boundary, hence why we need a property in the $state object, which will propagate changes.

oliverdowling avatar Jan 11 '25 11:01 oliverdowling

This is just a proof of concept and can be improved, but I think this is more what you're trying to do:

/// $lib/query_center.svelte.ts
export class QueryCenter {
	search = $state('');
	query = $state<{ value?: unknown; isLoading: boolean; error?: unknown }>({
		value: undefined,
		isLoading: true,
		error: undefined
	});
	constructor(db: Dexie) {
		$effect(() => {
			(() => [this.search])(); // no-op so it reloads
			return liveQuery(() =>
				db.repos.where('searchWords').startsWithAnyOfIgnoreCase(search).distinct().toArray()
			).subscribe(
				(value) => {
					this.query.error = undefined;
					if (value !== undefined) {
						this.query.value = value;
						this.query.isLoading = false;
					} else {
						this.query.isLoading = true;
					}
				},
				(error) => {
					this.query.error = error;
					this.query.value = undefined;
					this.query.isLoading = false;
				}
			).unsubscribe;
		});
	}
}

And now you can use it with less boilerplate:

/// $lib/Component.svelte
...
const queryCenter = new QueryCenter(db);
...
<input bind:value={queryCenter.search} />
{#each queryCenter.query.value ?? [] as repo (repo.id)}
...

oliverdowling avatar Jan 13 '25 01:01 oliverdowling

not sure this is actually better (i am also about 30 minutes in to using dexie), but in theory the part svelte tracks the the part live query track are different. More so hoping this triggers an idea in someone more familiar with dexie, but to me this is preferable over explicit dep tracking. would also be easy to write a wrapper for commonly used outputs like liveQueryArray for example, either in the adapter or userland.

so something like this would work as a primitive.

export class LiveQuery<T = any, TKey = IndexableType, TInsertType = T, TResult = T, TError = unknown> {
    data = $state<TResult>();
    state = $state<"error" | "loading" | "success">("loading");

    readonly isLoading = $derived(this.state === "loading");
    readonly isSuccess = $derived(this.state === "success");
    readonly isError = $derived(this.state === "error");
    error = $state<TError>();

    constructor(
        querier: () => Collection<T, TKey, TInsertType>,
        output: (query: Collection<T, TKey, TInsertType>) => PromiseExtended<TResult>
    ) {
        $effect(() => {
            const collection = querier();
            return liveQuery(() => output(collection)).subscribe(
                data => {
                    this.error = undefined;
                    if (data === undefined) {
                        this.state = "loading";
                    } else {
                        this.data = data;
                        this.state = "success";
                    }
                },
                error => {
                    this.error = error;
                    this.state = "error";
                }
            ).unsubscribe;
        });
    }
}

with usage like this

    let friendsQuery = new LiveQuery(
        () => db.friends.where("age").between(minAge, maxAge),
        c => c.toArray()
    );

jjones315 avatar Mar 07 '25 14:03 jjones315

This looks nice for simple synchronous queries that return a Dexie Collection. If you want to do a join, or do custom sorting/filtering so it's no longer a Collection, or have output be reactive (eg someBooleanState && c.count() > 0) then this approach will not work and you will need to declare dependencies again.

oliverdowling avatar Mar 11 '25 05:03 oliverdowling

Should I merge and release this library as it is today (maybe as a 1.0.0-alpha.1)?

dfahlander avatar Jul 05 '25 10:07 dfahlander