Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testPathIgnorePatterns: [
'/\\.yalc/'
],
};
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "leo-query",
"version": "0.5.0",
"version": "0.6.0-rc.2",
"description": "A simple library to connect async queries to Zustand stores.",
"type": "module",
"main": "./dist/index.cjs",
Expand Down
83 changes: 74 additions & 9 deletions src/events.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,109 @@
import {ErrorPayload, SuccessPayload, SettledPayload, LeoQueryEventTarget} from "./types";
import {RequestPayload, LeoQueryEventTarget, Effect, Query} from "./types";
import {isEffect, isQuery} from "./src";
import {StoreApi, UseBoundStore} from "zustand";

interface StoreEvent<T> {
store: UseBoundStore<StoreApi<T>>;
eventTarget: LeoQueryEventTarget;
}

interface EffectEvent<T> {
effect: Effect<T, any, any>;
eventTarget: LeoQueryEventTarget;
}

interface QueryEvent<State, T> {
query: Query<State, T>;
eventTarget: LeoQueryEventTarget;
}

const storeEvents: StoreEvent<any>[] = [];
const effectEvents: EffectEvent<any>[] = [];
const queryEvents: QueryEvent<any, any>[] = [];

export function createEvents<T>(store: UseBoundStore<StoreApi<T>>): LeoQueryEventTarget {
function createEventsFromStore<T>(store: UseBoundStore<StoreApi<T>>): LeoQueryEventTarget {
const e = new EventTarget();
const ee = {
addEventListener: (type: string, listener: (evt: CustomEvent<ErrorPayload | SuccessPayload | SettledPayload>) => void, options?: AddEventListenerOptions | boolean) =>
addEventListener: (type: string, listener: (evt: CustomEvent<RequestPayload>) => void, options?: AddEventListenerOptions | boolean) =>
e.addEventListener(type, listener as EventListener, options),
removeEventListener: (type: string, listener: (evt: CustomEvent<ErrorPayload | SuccessPayload | SettledPayload>) => void, options?: AddEventListenerOptions | boolean) =>
removeEventListener: (type: string, listener: (evt: CustomEvent<RequestPayload>) => void, options?: AddEventListenerOptions | boolean) =>
e.removeEventListener(type, listener as EventListener, options),
__dispatchEvent: (evt: CustomEvent<ErrorPayload | SuccessPayload | SettledPayload>) => {
__dispatchEvent: (evt: CustomEvent<RequestPayload>) => {
e.dispatchEvent(evt);
},
} as LeoQueryEventTarget;
storeEvents.push({store, eventTarget: ee});
return ee;
};


function createEventsFromEffect<T, Args extends any[], R>(effect: Effect<T, Args, R>): LeoQueryEventTarget {
const e = new EventTarget();
const ee = {
addEventListener: (type: string, listener: (evt: CustomEvent<RequestPayload>) => void, options?: AddEventListenerOptions | boolean) =>
e.addEventListener(type, listener as EventListener, options),
removeEventListener: (type: string, listener: (evt: CustomEvent<RequestPayload>) => void, options?: AddEventListenerOptions | boolean) =>
e.removeEventListener(type, listener as EventListener, options),
__dispatchEvent: (evt: CustomEvent<RequestPayload>) => {
e.dispatchEvent(evt);
},
} as LeoQueryEventTarget;
effectEvents.push({effect, eventTarget: ee});
return ee;
}

function createEventsFromQuery<State, T>(query: Query<State, T>): LeoQueryEventTarget {
const e = new EventTarget();
const ee = {
addEventListener: (type: string, listener: (evt: CustomEvent<RequestPayload>) => void, options?: AddEventListenerOptions | boolean) =>
e.addEventListener(type, listener as EventListener, options),
removeEventListener: (type: string, listener: (evt: CustomEvent<RequestPayload>) => void, options?: AddEventListenerOptions | boolean) =>
e.removeEventListener(type, listener as EventListener, options),
} as LeoQueryEventTarget;
queryEvents.push({query, eventTarget: ee});
return ee;
}

export function createEvents<T>(store: UseBoundStore<StoreApi<T>>): LeoQueryEventTarget;
export function createEvents<T, Args extends any[], R>(effect: Effect<T, Args, R>): LeoQueryEventTarget;
export function createEvents<State, T>(query: Query<State, T>): LeoQueryEventTarget;
export function createEvents(arg: any): LeoQueryEventTarget {
if (isEffect(arg)) {
return createEventsFromEffect(arg);
} else if (isQuery(arg)) {
return createEventsFromQuery(arg);
} else {
return createEventsFromStore(arg);
}

}

function createGlobalEventTarget(): LeoQueryEventTarget {
const events = new EventTarget();

return {
addEventListener: (type: string, listener: (evt: CustomEvent<ErrorPayload | SuccessPayload | SettledPayload>) => void, options?: AddEventListenerOptions | boolean) =>
addEventListener: (type: string, listener: (evt: CustomEvent<RequestPayload>) => void, options?: AddEventListenerOptions | boolean) =>
events.addEventListener(type, listener as EventListener, options),
removeEventListener: (type: string, listener: (evt: CustomEvent<ErrorPayload | SuccessPayload | SettledPayload>) => void, options?: AddEventListenerOptions | boolean) =>
removeEventListener: (type: string, listener: (evt: CustomEvent<RequestPayload>) => void, options?: AddEventListenerOptions | boolean) =>
events.removeEventListener(type, listener as EventListener, options),
__dispatchEvent: (evt: CustomEvent<ErrorPayload | SuccessPayload | SettledPayload>) => {
__dispatchEvent: (evt: CustomEvent<RequestPayload>) => {
const store = evt.detail.effect?.__store() ?? evt.detail.query?.__store();
events.dispatchEvent(evt);
storeEvents.forEach(s => {
if (s.store === store) {
s.eventTarget.__dispatchEvent(evt);
}
})
});
effectEvents.forEach(e => {
if (e.effect.__id === evt.detail.effect?.__id) {
e.eventTarget.__dispatchEvent(evt);
}
});
queryEvents.forEach(q => {
if (q.query.__id === evt.detail.query?.__id) {
q.eventTarget.__dispatchEvent(evt);
}
});
}
} as LeoQueryEventTarget;
};
Expand Down
182 changes: 182 additions & 0 deletions src/forms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { QueryValue, LeoRequest } from "./types";
import { useState, useEffect, Dispatch, SetStateAction, useRef } from "react";
import { equals } from "./src";

/**
* React hook that creates local state synchronized with a query's value.
* Useful for form inputs or other UI components that need local state management tied to a query.
* The local state will automatically update when the query's value changes.
*
* @param query - The query value object to synchronize state with.
* @param initialValue - Initial value for the local state. Guarantees non-undefined state.
* @returns A tuple containing the current state value and a setter function, similar to React's `useState`.
*
* @example
* ```tsx
* const [name, setName] = useQueryState(userQuery, '');
*
* return (
* <input
* value={name}
* onChange={(e) => setName(e.target.value)}
* />
* );
* ```
*/
export function useQueryState<T>(query: QueryValue<T>, initialValue: T): [T, Dispatch<SetStateAction<T>>];
/**
* React hook that creates local state synchronized with a query's value.
* Maps a property from the query value using a selector function.
*
* @param query - The query value object to synchronize state with.
* @param selector - Function to extract a property from the query value. Only the selected property is managed locally.
* @returns A tuple containing the current state value and a setter function, similar to React's `useState`.
*
* @example
* ```tsx
* // Query returns { name: 'John', age: 30 }
* const [name, setName] = useQueryState(userQuery, (user) => user.name, '');
*
* return (
* <input
* value={name}
* onChange={(e) => setName(e.target.value)}
* />
* );
* ```
*/
export function useQueryState<Q, T>(query: QueryValue<Q>, selector: (value: Q) => T): [T, Dispatch<SetStateAction<T>>];
/**
* React hook that creates local state synchronized with a query's value.
* Maps a property from the query value using a selector function.
*
* @param query - The query value object to synchronize state with.
* @param selector - Function to extract a property from the query value. Only the selected property is managed locally.
* @param initialValue - Initial value for the local state. Guarantees non-undefined state.
* @returns A tuple containing the current state value and a setter function, similar to React's `useState`.
*
* @example
* ```tsx
* // Query returns { name: 'John', age: 30 }
* const [name, setName] = useQueryState(userQuery, (user) => user.name, '');
*
* return (
* <input
* value={name}
* onChange={(e) => setName(e.target.value)}
* />
* );
* ```
*/
export function useQueryState<Q, T>(query: QueryValue<Q>, selector: (value: Q) => T, initialValue: T): [T, Dispatch<SetStateAction<T>>];
/**
* React hook that creates local state synchronized with a query's value.
*
* @param query - The query value object to synchronize state with.
* @param selector - Optional function to extract a property from the query value. When provided, only the selected property is managed locally.
* @param initialValue - Optional initial value for the local state.
* @returns A tuple containing the current state value (may be undefined) and a setter function, similar to React's `useState`.
*
* @example
* ```tsx
* // Without initial value - state may be undefined
* const [name, setName] = useQueryState(userQuery, (user) => user.name);
*
* return (
* <input
* value={name || ''}
* onChange={(e) => setName(e.target.value)}
* />
* );
* ```
*/
export function useQueryState<Q, T = Q>(query: QueryValue<Q>, selector?: (value: Q) => T, initialValue?: T): [T | undefined, Dispatch<SetStateAction<T | undefined>>] {
let _selector: (value: Q) => T | undefined;
if (selector && typeof selector === 'function') {
_selector = selector;
} else {
_selector = (value: Q) => value as unknown as T;
}

let _initialValue: T | undefined;
if (selector && typeof selector !== 'function') {
_initialValue = selector;
} else if (initialValue !== undefined) {
_initialValue = initialValue;
} else {
_initialValue = undefined;
}

const [state, setState] = useState<T | undefined>(_initialValue);

useEffect(() => {
if (query.value !== undefined) {
const value = _selector(query.value);
if (!equals(value, state)) {
setState(value);
}
}
}, [query.value]);

return [state, setState];
};

/**
* React hook that tracks whether a message should be displayed based on a query or effect request.
* The message automatically expires after a specified timeout. When a new request is provided,
* any existing timer is cleared and a new countdown begins from the start.
* Useful for displaying temporary success or error messages that should disappear after a few seconds.
*
* @param request - The query or effect request object to track. When this changes to a new request, the expiration timer resets and starts counting down again. Can be `query.lastCompletedRequest` or `effect.lastCompletedRequest`.
* @param timeout - The duration in milliseconds before the message expires. The timer starts immediately when a request is provided.
* @returns A boolean indicating whether the message has expired. Returns `false` initially and whenever a new request is provided, `true` after the timeout expires.
*
* @example
* ```tsx
* // With an effect request
* const { lastCompletedRequest, isLoading } = useDogStore(s => ({
* lastCompletedRequest: s.increasePopulation.lastCompletedRequest,
* isLoading: s.increasePopulation.isLoading
* }));
*
* const messageExpired = useMessageExpired(lastCompletedRequest, 3000);
* const showMessage = lastCompletedRequest && !isLoading && !messageExpired;
*
* return (
* <>
* <button onClick={trigger}>Submit</button>
* {showMessage && lastCompletedRequest.status === 'success' && <p>Success!</p>}
* {showMessage && lastCompletedRequest.status === 'error' && <p>Error!</p>}
* </>
* );
* ```
*
* @example
* ```tsx
* // With a query request
* const dogs = useDogStoreAsync(state => state.dogs);
* const messageExpired = useMessageExpired(dogs.lastCompletedRequest, 3000);
*
* if (dogs.isLoading) return <Loading />;
* if (dogs.error && !messageExpired) return <Error />;
* return <div>{dogs.value}</div>;
* ```
*/
export const useMessageExpired = <R>(request: LeoRequest<R> | undefined, timeout: number): boolean => {
const [messageExpired, setMessageExpired] = useState(false);
const timeoutRef = useRef<number>(0);
useEffect(() => {
if (request) {
clearTimeout(timeoutRef.current);
if (messageExpired) {
setMessageExpired(false);
}
const timeoutId = setTimeout(() => setMessageExpired(true), timeout) as unknown as number;
timeoutRef.current = timeoutId;
}
return () => {
clearTimeout(timeoutRef.current);
}
}, [request, timeout]);
return messageExpired;
};
6 changes: 5 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@ export * from "./types";
export {
events,
createEvents
} from "./events";
} from "./events";
export {
useQueryState,
useMessageExpired
} from "./forms";
Loading