@classic-homes/offline-react
v0.1.3
Published
React bindings for offline persistence and caching
Readme
@classic-homes/offline-react
React hooks for offline persistence and caching. Provides reactive file storage, form draft persistence, and data caching for React applications.
Features
- useFileStorage: Reactive file storage with validation and previews
- usePersistedForm: Automatic form draft persistence
- useOfflineCache: Data caching with TTL and stale-while-revalidate
- TypeScript: Full type safety with generics
- React 18/19: Compatible with latest React versions
Installation
npm install @classic-homes/offline-react @classic-homes/offline-coreQuick Start
import { useEffect } from 'react';
import { initOfflineStorage } from '@classic-homes/offline-core';
import { useFileStorage, usePersistedForm, useOfflineCache } from '@classic-homes/offline-react';
// Initialize storage at app startup
function App() {
useEffect(() => {
initOfflineStorage();
}, []);
return <MyForm />;
}Hooks
useFileStorage
Manage file attachments with validation, preview generation, and persistence.
import { useFileStorage } from '@classic-homes/offline-react';
function FileUploadField() {
const documents = useFileStorage({
formKey: 'contact-form',
fieldName: 'documents',
maxFiles: 5,
maxSizeBytes: 10 * 1024 * 1024, // 10MB
acceptedTypes: ['application/pdf', 'image/*'],
onValidationError: (errors) => {
errors.forEach((e) => toast.error(e.message));
},
});
const handleDrop = async (files: File[]) => {
await documents.addFiles(files);
};
return (
<div>
{documents.isLoading ? (
<Spinner />
) : (
<FileList files={documents.files} onRemove={documents.removeFile} />
)}
<DropZone onDrop={handleDrop} />
</div>
);
}Options
| Option | Type | Description |
| ------------------- | ---------- | ------------------------------------------------ |
| formKey | string | Form identifier |
| fieldName | string | Field name within the form |
| maxFiles | number | Maximum number of files |
| maxSizeBytes | number | Maximum file size in bytes |
| acceptedTypes | string[] | Accepted MIME types (supports wildcards) |
| expirationMs | number | File expiration time |
| generatePreviews | boolean | Generate preview URLs for images (default: true) |
| onValidationError | function | Callback for validation errors |
Return Value
| Property | Type | Description |
| ------------------- | ----------------- | -------------------------------- |
| files | PersistedFile[] | Current files |
| fileIds | string[] | File IDs for form submission |
| isLoading | boolean | Loading state |
| error | Error \| null | Current error |
| addFiles | function | Add files (validates and stores) |
| removeFile | function | Remove a file by ID |
| clearFiles | function | Remove all files |
| reload | function | Reload from storage |
| getFilesForSubmit | function | Get File objects for FormData |
usePersistedForm
Automatically persist form state across page reloads.
import { usePersistedForm } from '@classic-homes/offline-react';
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string(),
});
function ContactForm() {
const form = usePersistedForm({
formKey: 'contact-form',
defaultValues: { name: '', email: '', message: '' },
schema, // Optional Zod schema
debounceMs: 500,
excludeFields: ['password'], // Don't persist sensitive fields
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.isValid) return;
await submitForm(form.values);
await form.clearDraft();
};
return (
<form onSubmit={handleSubmit}>
{form.hasDraft && (
<button type="button" onClick={form.restoreDraft}>
Restore draft from {form.draftInfo?.timestamp}
</button>
)}
<input
name="name"
value={form.values.name}
onChange={(e) => form.setValue('name', e.target.value)}
/>
{form.errors.name && <span>{form.errors.name}</span>}
<input
name="email"
value={form.values.email}
onChange={(e) => form.setValue('email', e.target.value)}
/>
<textarea
name="message"
value={form.values.message}
onChange={(e) => form.setValue('message', e.target.value)}
/>
<button type="submit" disabled={!form.isValid || form.isSubmitting}>
Submit
</button>
</form>
);
}Options
| Option | Type | Description |
| --------------- | ----------- | ------------------------------------------ |
| formKey | string | Unique form identifier |
| defaultValues | T | Default form values |
| schema | ZodSchema | Optional Zod validation schema |
| debounceMs | number | Debounce time for auto-save (default: 500) |
| excludeFields | string[] | Fields to exclude from persistence |
| expirationMs | number | Draft expiration time |
| autoRestore | boolean | Auto-restore on mount (default: false) |
Return Value
| Property | Type | Description |
| -------------- | ---------------- | -------------------------------- |
| values | T | Current form values |
| errors | FieldErrors<T> | Validation errors by field |
| isValid | boolean | Whether form passes validation |
| isDirty | boolean | Whether form has unsaved changes |
| hasDraft | boolean | Whether a draft exists |
| draftInfo | object | Draft metadata |
| setValue | function | Set a field value |
| setValues | function | Set multiple values |
| reset | function | Reset to default values |
| saveDraft | function | Manually save draft |
| restoreDraft | function | Restore from draft |
| clearDraft | function | Delete the draft |
useOfflineCache
Cache data with TTL and stale-while-revalidate support.
import { useOfflineCache } from '@classic-homes/offline-react';
function UserProfile({ userId }: { userId: string }) {
const userCache = useOfflineCache<User>({
key: `user:${userId}`,
ttl: 5 * 60 * 1000, // 5 minutes
tags: ['user'],
staleWhileRevalidate: true,
});
useEffect(() => {
userCache.get(() => fetchUser(userId));
}, [userId]);
if (userCache.isLoading) return <Spinner />;
if (userCache.error) return <Error error={userCache.error} />;
if (!userCache.data) return null;
return (
<div>
<h1>{userCache.data.name}</h1>
{userCache.isStale && <span>Updating...</span>}
<button onClick={() => userCache.refresh(() => fetchUser(userId))}>Refresh</button>
</div>
);
}Options
| Option | Type | Description |
| ---------------------- | ---------- | --------------------------------- |
| key | string | Cache key |
| ttl | number | Time to live in milliseconds |
| tags | string[] | Tags for bulk invalidation |
| staleWhileRevalidate | boolean | Return stale data while fetching |
| staleTime | number | How long stale data is acceptable |
Return Value
| Property | Type | Description |
| -------------- | ---------------- | --------------------- |
| data | T \| null | Cached data |
| isLoading | boolean | Loading state |
| isStale | boolean | Whether data is stale |
| error | Error \| null | Current error |
| ttlRemaining | number \| null | Time until expiration |
| get | function | Get cached or fetch |
| set | function | Set cache value |
| invalidate | function | Invalidate this key |
| refresh | function | Force re-fetch |
| has | function | Check if cached |
Utility Functions
import { invalidateCacheByTag, invalidateAllCache } from '@classic-homes/offline-react';
// Invalidate all entries with a tag
await invalidateCacheByTag('user');
// Invalidate entire cache
await invalidateAllCache();TypeScript
All hooks are fully typed with generics:
interface User {
id: string;
name: string;
email: string;
}
const userCache = useOfflineCache<User>({ key: 'user' });
// userCache.data is User | null
const form = usePersistedForm<User>({
formKey: 'user',
defaultValues: { id: '', name: '', email: '' },
});
// form.values is User
// form.setValue('name', value) - 'name' is typedTesting
npm test # Run tests
npm run test:watch # Watch mode
npm run test:coverage # With coverageLicense
MIT
