UNPKG

instantdb-react-ui

Version:

Customizable react components for InstantDB (forms/lists/etc.)

281 lines (280 loc) 13.7 kB
import { id } from '@instantdb/react'; import { useForm } from '@tanstack/react-form'; import { useCallback, useEffect, useRef } from 'react'; import { internalCreateIDBEntityZodSchema } from '../form/zod'; import { logger } from '../utils/logger'; /** Checks if 2 values are different. Treats undefined and null as equal values */ export const isDifferent = (a, b) => { if ((a === undefined && b === null) || (a === null && b === undefined)) { return false; } return JSON.stringify(a) !== JSON.stringify(b); }; /** An InstantDB wrapper for Tanstack Form. Gain type-safety and automatic database syncing. */ export function useIDBForm(options) { // Refs for managing timers and state const timerRef = useRef(null); const lastUpdateRef = useRef({}); const pendingThrottledUpdatesRef = useRef({}); const pendingFieldRef = useRef(null); const throttleTimerRef = useRef({}); // Cleanup timers useEffect(() => { return () => { if (timerRef.current) clearTimeout(timerRef.current); Object.values(throttleTimerRef.current).forEach(clearTimeout); pendingFieldRef.current = null; }; }, []); /** Updates the database with the current form values */ const handleIDBUpdate = useCallback(() => { if (options.idbOptions.type !== 'update' || !form) return; // For reference, this is what the update code does without any debounce/throttle // const formState = form.store.state.values; // const formStateKeys = Object.keys(formState as any); // let transactions: TransactionChunk<InstantSchemaDef<any, any, any>, string>[] = []; // for (const fieldName of formStateKeys) { // transactions = transactions.concat(updateIDB(fieldName)); // } // if (transactions.length > 0) db.transact(transactions); const formStateKeys = Object.keys(form.store.state.values); const fieldsToUpdate = formStateKeys.filter(checkIDBNeedsUpdate); if (fieldsToUpdate.length === 0) return; // Handle multiple fields or different pending field - immediate update if (fieldsToUpdate.length > 1 || (pendingFieldRef.current && !fieldsToUpdate.includes(pendingFieldRef.current))) { clearTimeout(timerRef.current); timerRef.current = pendingFieldRef.current = null; pendingThrottledUpdatesRef.current = {}; const transactions = fieldsToUpdate.flatMap(getIDBUpdateTransactions); if (transactions.length > 0) db.transact(transactions); return; } const fieldName = fieldsToUpdate[0]; const debounceTime = options.idbOptions.serverDebounceFields?.[fieldName] ?? 0; const throttleTime = options.idbOptions.serverThrottleFields?.[fieldName] ?? 0; const executeUpdate = () => { const transactions = getIDBUpdateTransactions(fieldName); if (transactions.length > 0) db.transact(transactions); return Date.now(); }; // Handle debounce if (debounceTime > 0) { clearTimeout(timerRef.current); pendingFieldRef.current = fieldName; timerRef.current = setTimeout(() => { executeUpdate(); timerRef.current = pendingFieldRef.current = null; }, debounceTime); return; } // Handle throttle if (throttleTime > 0) { const now = Date.now(); const lastUpdate = lastUpdateRef.current[fieldName] ?? 0; const timeSinceLastUpdate = now - lastUpdate; clearTimeout(throttleTimerRef.current[fieldName]); if (timeSinceLastUpdate >= throttleTime) { lastUpdateRef.current[fieldName] = executeUpdate(); pendingThrottledUpdatesRef.current[fieldName] = false; } else if (!pendingThrottledUpdatesRef.current[fieldName]) { pendingThrottledUpdatesRef.current[fieldName] = true; setTimeout(() => { if (pendingThrottledUpdatesRef.current[fieldName]) { lastUpdateRef.current[fieldName] = executeUpdate(); pendingThrottledUpdatesRef.current[fieldName] = false; } }, throttleTime - timeSinceLastUpdate); } // Final update timer throttleTimerRef.current[fieldName] = setTimeout(() => { lastUpdateRef.current[fieldName] = executeUpdate(); pendingThrottledUpdatesRef.current[fieldName] = false; delete throttleTimerRef.current[fieldName]; }, throttleTime); return; } // No debounce or throttle - immediate update executeUpdate(); }, [options]); /** Creates a new entity in the database with the current form values */ const handleIDBCreate = useCallback(async () => { if (options.idbOptions.type !== 'create' || !form) return; const value = form.state.values; const linkValues = {}; const normalValues = {}; for (const [fieldName, fieldValue] of Object.entries(value)) { if (links[fieldName]) { linkValues[fieldName] = fieldValue; } else { normalValues[fieldName] = fieldValue; } } const newId = id(); let baseTransaction = db.tx[entityName][newId].update(normalValues); for (const [fieldName, fieldValue] of Object.entries(linkValues)) { const link = links[fieldName]; if (link) { if (link.cardinality === 'many') { const linkValues = fieldValue; baseTransaction = baseTransaction.link({ [fieldName]: linkValues.map(val => val.id) }); } else if (link.cardinality === 'one') { const linkValue = fieldValue; baseTransaction = baseTransaction.link({ [fieldName]: linkValue.id }); } } } await db.transact(baseTransaction); return newId; }, [options]); const idbOptions = options.idbOptions; const db = idbOptions.db; // Get all options from the callback const entityName = idbOptions.entity; const queryValueRef = useRef(null); // const { db } = useNewReactContext(); // Extract link names from the query const queryLinkNames = Object.keys(idbOptions.query[entityName] || {}).filter(key => key !== '$'); // Get only the links that are in the query. We don't want to add all links to our form and subscriptions const entity = idbOptions.schema.entities[entityName]; const allLinks = entity.links; const links = Object.fromEntries(Object.entries(allLinks).filter(([key]) => queryLinkNames.includes(key))); // Use the extracted function to create schema and get defaults const { zodSchema, defaults: zodDefaults } = internalCreateIDBEntityZodSchema(entity, links); // Merge default values from options with zod/instant defaults for (const [fieldName, fieldValue] of Object.entries(idbOptions.defaultValues || {})) { zodDefaults[fieldName] = fieldValue; } // Create tanstack form const idbApi = { handleIDBUpdate, handleIDBCreate, zodSchema }; const tanstackOptions = options.tanstackOptions(idbApi); const form = useForm({ ...tanstackOptions, defaultValues: zodDefaults, }); // -------------------------------------------------------------------------------- // IDB Query - receive external db changes and update form useEffect(() => { // ---------------------------------------- // Sync form with database const unsubscribers = []; if (idbOptions.type === 'update') { const unsubscribe = db._core.subscribeQuery(idbOptions.query, (resp) => { if (resp.error) { logger.error(resp.error.message); return; } if (resp.data) { const item = resp.data[idbOptions.entity]?.[0]; queryValueRef.current = item; for (const [fieldName, fieldValue] of Object.entries(item)) { let newValue = fieldValue; const prevValue = form.getFieldValue(fieldName); // For relations, use the id as value. When unlinking, the value is undefined if (links[fieldName] && !newValue) newValue = ''; // Update the form if the value has changed if (isDifferent(prevValue, newValue)) { logger.log(`Received Update for ${fieldName}: ${JSON.stringify(newValue)}`); form.setFieldValue(fieldName, newValue); } if (!form.getFieldMeta(fieldName)?.[idbSyncedMetaName]) { form.setFieldMeta(fieldName, prevMeta => ({ ...prevMeta, [idbSyncedMetaName]: true })); } } form.validate('change'); } }); unsubscribers.push(unsubscribe); } // ---------------------------------------- // Link picker queries for (const [fieldName, link] of Object.entries(links)) { const linkPickerQuery = idbOptions.linkPickerQueries?.[fieldName] || { [link.entityName]: {}, }; const unsubscribe = db._core.subscribeQuery(linkPickerQuery, (resp) => { const linkPickerData = resp.data?.[link.entityName]; form.setFieldMeta(fieldName, prevMeta => ({ ...prevMeta, [idbLinkDataMetaName]: linkPickerData })); }); unsubscribers.push(unsubscribe); } // Cleanup function to unsubscribe from all queries return () => { unsubscribers.forEach(unsubscribe => unsubscribe()); }; }, [idbOptions]); // -------------------------------------------------------------------------------- // Check if a field needs to be updated const checkIDBNeedsUpdate = (fieldName) => { const oldValue = queryValueRef.current[fieldName]; const newValue = form.getFieldValue(fieldName); const needsUpdate = isDifferent(oldValue, newValue); if (needsUpdate) form.setFieldMeta(fieldName, prevMeta => ({ ...prevMeta, [idbSyncedMetaName]: false })); return needsUpdate; }; // -------------------------------------------------------------------------------- // Update a field in InstantDB const getIDBUpdateTransactions = (fieldName) => { const transactions = []; if (idbOptions.type === 'create') return transactions; const oldValue = queryValueRef.current[fieldName]; const newValue = form.getFieldValue(fieldName); // Skip update if the value hasn't changed. if (!isDifferent(oldValue, newValue)) { logger.log(`Skipping server update for field: ${fieldName}`); return transactions; } logger.log(`Server Update: ${fieldName} from ${JSON.stringify(oldValue)} to ${JSON.stringify(newValue)}`); const id = form.getFieldValue('id'); const link = links[fieldName]; const tx = db.tx[entityName][id]; if (link) { // Relation field db update const cardinality = link.cardinality; if (cardinality === 'many') { // Find the difference between the old and new values, then unlink the old values and link the new values const newValueList = newValue; // Imagine person A and B is linked. We unlink A (which updates) then unlink B (which doesn't update because the field requires at least one link and we have a validation error.). Now the field is empty, but when we link person C, we fail to unlink person B beause prevValue was null. The solution to this is to use the queryValue from the database as the prevValue instead of the field value const prevValues = oldValue; const idsToUnlink = prevValues .filter((item) => !newValueList.some(newItem => newItem.id === item.id)) .map(item => item.id); const idsToLink = newValueList .filter((item) => !prevValues.some(prevItem => prevItem.id === item.id)) .map(item => item.id); if (idsToUnlink.length > 0) transactions.push(tx.unlink({ [fieldName]: idsToUnlink })); if (idsToLink.length > 0) transactions.push(tx.link({ [fieldName]: idsToLink })); } else if (cardinality === 'one') { // Unlink the old value and link the new value const newValueSingle = newValue; if (!oldValue || oldValue.id !== newValueSingle.id) { transactions.push(tx.link({ [fieldName]: newValueSingle.id })); } } } else { // Normal field db update transactions.push(tx.update({ [fieldName]: newValue })); } return transactions; }; const newForm = form; newForm.idb = idbApi; return newForm; } const idbSyncedMetaName = 'idbSynced'; const idbLinkDataMetaName = 'idbLinkData';