Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from

Conversation

oliverdowling
Copy link

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

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

@dfahlander
Copy link
Collaborator

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

@DanConwayDev
Copy link

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;
}

@oliverdowling
Copy link
Author

oliverdowling commented Jan 9, 2025

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
Copy link
Author

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
);

@DanConwayDev
Copy link

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.

@oliverdowling
Copy link
Author

oliverdowling commented Jan 10, 2025

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 }]
);

@DanConwayDev
Copy link

DanConwayDev commented Jan 10, 2025

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.

@oliverdowling
Copy link
Author

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
Copy link
Author

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)}
...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: In progress
Development

Successfully merging this pull request may close these issues.

3 participants