geoform
v0.1.0
Published
React Hierarchical Form Stack System - infinitely nestable forms with state preservation
Maintainers
Readme
geoform
React Hierarchical Form Stack System - infinitely nestable forms with state preservation.
Features
- Infinitely Nestable Forms - Stack forms within forms without limits; parent state is preserved
- Promise-Based API -
openForm()returns a Promise that resolves when the form closes - Full TypeScript Support - Generics flow from form definition to result handling
- Built-in Breadcrumb Navigation - Click any breadcrumb to navigate back through the form hierarchy
- Error Boundaries Per Form - Crashes in one form don't affect parent forms
Installation
npm install geoformyarn add geoformpnpm add geoformPeer Dependencies: React 18 or 19
npm install react react-domQuick Start
import { useState } from 'react';
import { FormStackProvider, useFormStack, type FormProps } from 'geoform';
// 1. Define your form with FormProps<T>
interface UserData {
name: string;
}
function UserForm({ onSubmit, onCancel }: FormProps<UserData>) {
const [name, setName] = useState('');
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<button type="button" onClick={() => onSubmit({ name })}>Save</button>
<button type="button" onClick={onCancel}>Cancel</button>
</div>
);
}
// 2. Use openForm() to open forms and await results
function CreateButton() {
const { openForm } = useFormStack();
const handleClick = async () => {
const result = await openForm<UserData>({
id: 'create-user',
component: UserForm,
label: 'Create User',
});
if (result) {
console.log('Created:', result.name);
}
// If undefined, user cancelled
};
return <button onClick={handleClick}>Create User</button>;
}
// 3. Wrap your app with FormStackProvider
function App() {
return (
<FormStackProvider>
<CreateButton />
</FormStackProvider>
);
}Core Concepts
Form Stack
A stack of suspended form components where only the top form is visible. When you call openForm(), your current form is hidden (not unmounted) and the new form appears on top.
State Preservation
Parent forms remain mounted while children are active. All useState, useRef, and other React state is preserved automatically. When the child form closes, the parent reappears with its state intact.
Promise-Based API
openForm() returns a Promise that resolves when the form closes:
- Submit: Resolves with the value passed to
onSubmit(value) - Cancel: Resolves with
undefined
const result = await openForm<UserData>({ ... });
if (result) {
// User submitted - result is UserData
} else {
// User cancelled - result is undefined
}Breadcrumb Navigation
The <Breadcrumbs /> component displays the form hierarchy. Clicking a breadcrumb navigates directly to that form, cancelling all forms above it in the stack.
Error Isolation
Each form is wrapped in an error boundary. If a form crashes, parent forms are unaffected. Users can retry or dismiss the failed form.
API Reference
Components
FormStackProvider
Enables form stack functionality. Wrap your application with this component.
import { FormStackProvider } from 'geoform';
function App() {
return (
<FormStackProvider>
<YourApp />
</FormStackProvider>
);
}Props: None required. Children are rendered normally.
Breadcrumbs
Displays navigable breadcrumbs for the form stack.
import { Breadcrumbs } from 'geoform';
<Breadcrumbs separator=" › " className="my-breadcrumbs" />Props:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| separator | ReactNode | "/" | Separator between breadcrumb items |
| className | string | "" | CSS class for the nav element |
| ariaLabel | string | "Form navigation" | Accessibility label |
CSS Classes:
.breadcrumbs /* nav element container */
.breadcrumbs__list /* ol element */
.breadcrumbs__item /* li element for each entry */
.breadcrumbs__link /* a element for clickable items */
.breadcrumbs__current /* span element for current form */
.breadcrumbs__separator /* span element for separators */Returns: null when stack is empty.
ConfirmationDialog
Accessible modal dialog for cancel confirmation. Uses native HTML <dialog> element.
import { ConfirmationDialog } from 'geoform';
<ConfirmationDialog
isOpen={showConfirm}
title="Discard Changes?"
message="Your unsaved changes will be lost."
confirmLabel="Discard"
cancelLabel="Keep Editing"
onConfirm={handleConfirm}
onCancel={handleCancel}
/>Props:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| isOpen | boolean | required | Whether dialog is visible |
| title | string | "Discard Changes?" | Dialog title |
| message | string | "Your unsaved changes will be lost." | Dialog message |
| confirmLabel | string | "Discard" | Confirm button text |
| cancelLabel | string | "Keep Editing" | Cancel button text |
| onConfirm | () => void | required | Called when user confirms |
| onCancel | () => void | required | Called when user cancels |
CSS Classes:
.confirmation-dialog
.confirmation-dialog__content
.confirmation-dialog__title
.confirmation-dialog__message
.confirmation-dialog__actions
.confirmation-dialog__button
.confirmation-dialog__button--cancel
.confirmation-dialog__button--confirmFormErrorBoundary
Error boundary for isolating form rendering errors. Provides retry and dismiss options.
import { FormErrorBoundary } from 'geoform';
<FormErrorBoundary
formId="user-form"
onDismiss={() => closeForm()}
onError={(error, info) => logToService(error)}
>
<UserForm />
</FormErrorBoundary>Props:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| children | ReactNode | required | Form component to wrap |
| formId | string | required | Unique form identifier |
| onDismiss | () => void | required | Called when dismiss is clicked |
| onError | (error, info) => void | - | Called when error is caught |
| fallback | ReactNode | - | Custom error UI |
CSS Classes:
.form-error-boundary
.form-error-boundary__container
.form-error-boundary__title
.form-error-boundary__message
.form-error-boundary__actions
.form-error-boundary__retry-button
.form-error-boundary__dismiss-buttonHooks
useFormStack
Primary hook for form stack interactions. Returns state and actions.
import { useFormStack } from 'geoform';
function MyComponent() {
const { stack, openForm, closeForm } = useFormStack();
const handleCreate = async () => {
const result = await openForm<UserData>({
id: 'create-user',
component: CreateUserForm,
label: 'Create User',
confirmOnCancel: true,
});
if (result) {
console.log('Created:', result);
}
};
return <button onClick={handleCreate}>Create</button>;
}Returns:
| Property | Type | Description |
|----------|------|-------------|
| stack | readonly StackEntry[] | Current form stack |
| openForm | <T>(options) => Promise<T \| undefined> | Opens a form and awaits result |
| closeForm | () => void | Closes the current form |
useFormStackState
Read-only state hook. Use when you only need to display stack info.
More performant than useFormStack - doesn't re-render when actions are called.
import { useFormStackState } from 'geoform';
function StackCounter() {
const { stack } = useFormStackState();
return <span>Forms open: {stack.length}</span>;
}Returns:
| Property | Type | Description |
|----------|------|-------------|
| stack | readonly StackEntry[] | Current form stack |
useFormStackActions
Actions-only hook. Use when you only need to dispatch actions.
More performant than useFormStack - doesn't re-render when stack changes.
import { useFormStackActions } from 'geoform';
function CreateButton() {
const { openForm } = useFormStackActions();
return <button onClick={() => openForm({ ... })}>Create</button>;
}Returns:
| Property | Type | Description |
|----------|------|-------------|
| openForm | <T>(options) => Promise<T \| undefined> | Opens a form |
| closeForm | () => void | Closes the current form |
| popToIndex | (index: number) => void | Navigates to form at index |
useFormStackURLSync
Syncs form stack state with URL query parameters. Enables shareable URLs and browser back/forward navigation.
import { FormStackProvider, useFormStackURLSync } from 'geoform';
function App() {
return (
<FormStackProvider>
<URLSyncedApp />
</FormStackProvider>
);
}
function URLSyncedApp() {
// Forms appear in URL as ?forms=form1,form2
useFormStackURLSync();
return <YourApp />;
}Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| paramName | string | "forms" | Query parameter name |
| restoreOnMount | boolean | true | Restore stack from URL on mount |
| syncToUrl | boolean | true | Sync stack changes to URL |
| syncFromUrl | boolean | true | Sync URL changes to stack |
| onRestore | (formIds: string[]) => void | - | Called when restoring from URL |
Returns:
| Property | Type | Description |
|----------|------|-------------|
| isRestoring | boolean | Whether currently restoring from URL |
| getUrlState | () => string[] | Get form IDs from URL |
| forceUrlUpdate | () => void | Manually trigger URL update |
Types
FormProps<T>
Props interface that all form components must implement.
import type { FormProps } from 'geoform';
interface UserData {
name: string;
email: string;
}
function UserForm({ onSubmit, onCancel, onError }: FormProps<UserData>) {
// onSubmit expects UserData
// onCancel takes no arguments
// onError is optional
}Definition:
interface FormProps<T = unknown> {
/** Called when form submits with the form's return value */
onSubmit: (value: T) => void;
/** Called when form is canceled */
onCancel: () => void;
/** Optional error handler for form-level errors */
onError?: (error: unknown) => void;
}OpenFormOptions<T>
Options passed to openForm().
const result = await openForm<UserData>({
id: 'create-user',
component: UserForm,
label: 'Create User',
confirmOnCancel: true,
});Definition:
interface OpenFormOptions<T = unknown> {
/** Unique identifier for this form instance */
id: string;
/** The form component to render (must accept FormProps<T>) */
component: ComponentType<FormProps<T>>;
/** Optional label displayed in breadcrumbs */
label?: string;
/** If true, shows confirmation dialog before cancel */
confirmOnCancel?: boolean;
}StackEntry
Public view of a form in the stack.
interface StackEntry {
/** Unique identifier for the form */
id: string;
/** Optional display label for breadcrumbs */
label?: string;
}FormStackState
Read-only state returned by useFormStackState.
interface FormStackState {
/** Current form stack (read-only) */
stack: readonly StackEntry[];
}FormStackActions
Actions returned by useFormStackActions.
interface FormStackActions {
openForm: <T>(options: OpenFormOptions<T>) => Promise<T | undefined>;
closeForm: () => void;
popToIndex: (index: number) => void;
}Advanced Usage
URL Sync
Enable URL synchronization for shareable form states:
function App() {
return (
<FormStackProvider>
<URLSyncedApp />
</FormStackProvider>
);
}
function URLSyncedApp() {
const { isRestoring } = useFormStackURLSync({
paramName: 'forms',
onRestore: (formIds) => {
// Load form components based on IDs
console.log('Restoring forms:', formIds);
},
});
if (isRestoring) {
return <div>Loading...</div>;
}
return <YourApp />;
}URL format: ?forms=form1,form2,form3
Browser back/forward buttons navigate through form history.
Form Restoration
The onRestore callback receives form IDs from the URL, but you must implement the actual form opening logic. This is intentional—geoform treats forms as black-box components managed by you, not the library.
import { useFormStack, useFormStackURLSync, type FormProps } from 'geoform';
// Map form IDs to their components and optional labels
function getFormComponent(formId: string) {
switch (formId) {
case 'user-form':
return { component: UserForm, label: 'User' };
case 'org-form':
return { component: OrgForm, label: 'Organization' };
case 'team-form':
return { component: TeamForm, label: 'Team' };
default:
console.warn(`Unknown form ID: ${formId}`);
return null;
}
}
function URLSyncedApp() {
const { openForm } = useFormStack();
const { isRestoring } = useFormStackURLSync({
paramName: 'forms',
onRestore: async (formIds) => {
for (const formId of formIds) {
const entry = getFormComponent(formId);
if (entry) {
await openForm({
id: formId,
component: entry.component,
label: entry.label,
});
}
}
},
});
if (isRestoring) {
return <div>Restoring forms...</div>;
}
return <YourApp />;
}Note: geoform does not include a form registry. This is an intentional design decision—forms are managed by you, not the library. Full auto-restore would require a registry pattern, which would add complexity and reduce flexibility.
Best Practices:
- Handle unknown form IDs gracefully (don't crash on invalid URLs)
- Show a loading state during restoration using
isRestoring - Remember: the URL only tracks form IDs, not form data or user input
Confirmation Dialogs
Prevent accidental data loss with confirmation dialogs:
const result = await openForm<UserData>({
id: 'edit-user',
component: EditUserForm,
label: 'Edit User',
confirmOnCancel: true, // Shows dialog before cancel
});The default dialog asks "Discard Changes?" with "Keep Editing" and "Discard" buttons.
Error Boundaries
Each form is automatically wrapped in an error boundary. For custom error handling:
// In your form component
function MyForm({ onSubmit, onCancel, onError }: FormProps<Data>) {
// onError is called when the error boundary catches an error
// Use it for logging to external services
}
// The error boundary provides default UI with:
// - Error message display
// - "Try Again" button (re-renders the form)
// - "Dismiss" button (closes the form)Custom Breadcrumb Styling
Style breadcrumbs with CSS using the provided class names:
.breadcrumbs {
padding: 1rem;
background: #f5f5f5;
}
.breadcrumbs__list {
display: flex;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.breadcrumbs__link {
color: #0066cc;
text-decoration: none;
}
.breadcrumbs__link:hover {
text-decoration: underline;
}
.breadcrumbs__current {
color: #333;
font-weight: bold;
}
.breadcrumbs__separator {
color: #999;
}Common Pitfalls
Avoid these common mistakes when working with geoform to ensure your forms work as intended.
Calling closeForm() Directly Instead of Using onSubmit/onCancel
Problem: Calling closeForm() directly from a form component bypasses the Promise resolution pattern.
❌ BAD - Direct closeForm call in a form component:
// src/components/MyForm.tsx
function MyForm({ onSubmit, onCancel }: FormProps<Data>) {
const { closeForm } = useFormStack();
const handleSave = () => {
onSubmit(data);
closeForm(); // WRONG! FormStackRenderer handles this via onSubmit
};
}✅ GOOD - Use onSubmit/onCancel props:
// src/components/MyForm.tsx
function MyForm({ onSubmit, onCancel }: FormProps<Data>) {
const handleSave = () => {
onSubmit(data); // FormStackRenderer will call closeForm() internally
};
const handleCancel = () => {
onCancel(); // FormStackRenderer will call closeForm() internally
};
}Why it's problematic: Direct closeForm() calls dispatch POP_FORM directly to the reducer, bypassing the Promise resolution pattern that openForm() returns. This breaks the parent's await and can cause unexpected behavior.
Valid use case: Programmatic form closure from a parent component (outside the form stack) is appropriate:
// ParentComponent.tsx
function ParentComponent() {
const { closeForm, stack } = useFormStack();
const handleEmergencyClose = () => {
while (stack.length > 0) {
closeForm(); // OK: Called from outside the form stack
}
};
}@see API Reference > useFormStack for more details.
Expecting URL Sync to Auto-Restore Forms
Problem: Forms don't automatically render when sharing URLs with form stack state.
❌ BAD - Expecting auto-restore:
// App.tsx
function App() {
// ❌ This won't auto-restore forms from URL
useFormStackURLSync();
return <MyApp />;
}✅ GOOD - Implementing onRestore callback:
// App.tsx
function getFormComponent(formId: string) {
switch (formId) {
case 'user-form':
return { component: UserForm, label: 'User' };
case 'org-form':
return { component: OrgForm, label: 'Organization' };
default:
console.warn(`Unknown form ID: ${formId}`);
return null;
}
}
function URLSyncedApp() {
const { openForm } = useFormStack();
const { isRestoring } = useFormStackURLSync({
paramName: 'forms',
onRestore: async (formIds) => {
for (const formId of formIds) {
const entry = getFormComponent(formId);
if (entry) {
await openForm({
id: formId,
component: entry.component,
label: entry.label,
});
}
}
},
});
if (isRestoring) {
return <div>Restoring forms...</div>;
}
return <MyApp />;
}Why it doesn't work: geoform does not include a form registry. This is an intentional design decision—the library treats forms as black-box components managed by you. URL sync can encode form IDs but cannot auto-restore forms without component references.
Note: A form registry would add complexity and reduce flexibility. Manual restoration keeps the library simple and gives you full control over which forms can be opened via URL.
@see Advanced Usage > URL Sync > Form Restoration for complete implementation guide.
Using Retry for Structural Errors vs Transient Errors
Problem: Clicking "Try Again" for structural errors will always fail.
❌ BAD - Expecting retry to fix structural errors:
// This form will ALWAYS throw - props are invalid
<UserForm userId={undefined} /> // Component requires userId propWhen this form throws and the error boundary appears, clicking "Try Again" won't help—the prop is still undefined.
✅ GOOD - Use Dismiss for structural errors:
// Fix the underlying prop issue
<UserForm userId={validId} /> // Valid propOr click "Dismiss" to close the form and fix the prop in the parent component.
Why retry sometimes doesn't work: The retry mechanism increments retryCount to force a component remount, but children receive the exact same props as before the error.
✅ Retry works for transient errors:
- Network failures that may succeed on retry
- Temporary rendering bugs or race conditions
- Component state corruption that resets on remount
❌ Retry won't work for structural errors:
- Invalid or malformed props (like
undefineduserId) - Type mismatches or missing required data
- Logic errors in the component's render method
- Invalid or malformed props (like
@see API Reference > FormErrorBoundary for error handling patterns.
Forgetting to Wrap App in FormStackProvider
Problem: All geoform hooks must be used within a FormStackProvider.
❌ BAD - Missing provider:
// App.tsx
function App() {
return <MyApp />; // No provider!
}
function MyApp() {
const { openForm } = useFormStack(); // ❌ THROWS ERROR
// Error: useFormStackState must be used within a FormStackProvider
}✅ GOOD - Proper provider setup:
// App.tsx
function App() {
return (
<FormStackProvider>
<MyApp />
</FormStackProvider>
);
}
function MyApp() {
const { openForm } = useFormStack(); // ✅ Works!
}Error you'll see: useFormStackState must be used within a FormStackProvider
@see API Reference > FormStackProvider for provider setup.
Calling useFormStack Outside Provider
Problem: Using geoform hooks outside the provider context causes runtime errors.
❌ BAD - Hook outside provider:
// utils.ts - file outside React component tree
export function openUserForm() {
const { openForm } = useFormStack(); // ❌ THROWS ERROR
// Error: useFormStackState must be used within a FormStackProvider
}✅ GOOD - Hook inside provider (within React component):
// UserButton.tsx - React component within provider tree
function UserButton() {
const { openForm } = useFormStack(); // ✅ Works!
const handleClick = async () => {
const result = await openForm({
id: 'user-form',
component: UserForm,
label: 'User',
});
if (result) {
console.log('Created user:', result);
}
};
return <button onClick={handleClick}>Create User</button>;
}Error you'll see: useFormStackState must be used within a FormStackProvider
Note: React hooks (including geoform hooks) can only be called from React function components or other hooks. They cannot be called from regular functions, utility modules, or class components.
Not Handling Async Form Submission Properly
Problem: Not understanding the Promise-based form submission pattern.
❌ BAD - Not awaiting or checking result:
// ParentComponent.tsx
function ParentComponent() {
const { openForm } = useFormStack();
const handleClick = () => {
openForm<UserData>({
id: 'create-user',
component: UserForm,
label: 'Create User',
});
// ❌ Not awaiting! Can't use result.
};
}✅ GOOD - Async/await with result handling:
// ParentComponent.tsx
function ParentComponent() {
const { openForm } = useFormStack();
const handleClick = async () => {
const result = await openForm<UserData>({
id: 'create-user',
component: UserForm,
label: 'Create User',
});
if (result) {
// User submitted - result is UserData
console.log('Created user:', result.name);
} else {
// User cancelled - result is undefined
console.log('User cancelled');
}
};
}How it works: openForm() returns a Promise<T | undefined> that resolves when the form closes:
- Submit: Resolves with the value passed to
onSubmit(value) - Cancel: Resolves with
undefined
The form component itself should just call onSubmit(data) or onCancel()—the Promise pattern is handled by geoform.
@see Core Concepts > Promise-Based API and API Reference > useFormStack.
TypeScript
Basic Usage
TypeScript infers types automatically in most cases:
function UserForm({ onSubmit, onCancel }: FormProps<UserData>) {
// onSubmit is typed as (value: UserData) => void
}Typed Form Data
Define your data type and use it with FormProps<T>:
interface UserData {
name: string;
email: string;
role: 'admin' | 'member' | 'viewer';
}
function UserForm({ onSubmit, onCancel }: FormProps<UserData>) {
const handleSubmit = () => {
onSubmit({
name: 'John',
email: '[email protected]',
role: 'member', // TypeScript enforces valid values
});
};
}Typed openForm
Specify the type parameter to get typed results:
const result = await openForm<UserData>({
id: 'create-user',
component: UserForm,
label: 'Create User',
});
if (result) {
// result is typed as UserData
console.log(result.name); // OK
console.log(result.email); // OK
console.log(result.foo); // TypeScript Error!
}Type Flow
Types flow from form definition through to result:
FormProps<T> → OpenFormOptions<T> → Promise<T | undefined>This ensures type safety from form creation to result handling.
Examples
See the examples/relational-forms directory for a complete working example demonstrating:
- Three-level form hierarchy: Organization → Team → User
- State preservation across nested forms
- Breadcrumb navigation
- Confirmation dialogs
- Type-safe form data flow
Browser Support
geoform targets modern browsers with ES2020+ support. The ConfirmationDialog component uses the native HTML <dialog> element for accessible modal dialogs.
Minimum Browser Versions
| Browser | Minimum Version | Support | Notes | |---------|----------------|------------------|-------| | Chrome | 37+ | ✅ Native | Full support including showModal() | | Firefox | 98+ | ✅ Native | Required preference flag in earlier versions | | Safari | 15.4+ | ✅ Native | iOS Safari 15.4+ also supported | | Edge | 79+ | ✅ Native | Chromium-based Edge | | Opera | 24+ | ✅ Native | Based on Chromium | | Internet Explorer | All | ❌ No | Requires polyfill |
Global Support: ~98.5% of users have native support (source)
Feature Detection
The ConfirmationDialog component includes runtime feature detection:
// Feature detection implemented in ConfirmationDialog
if (typeof dialog.showModal === 'function') {
dialog.showModal();
}This prevents errors in browsers without native <dialog> support—the component simply won't render the modal in those browsers.
Polyfill for Older Browsers
If you need to support older browsers (Internet Explorer, Safari < 15.4, Firefox < 98), use the GoogleChrome/dialog-polyfill:
npm install dialog-polyfillThen register the polyfill before using ConfirmationDialog:
import dialogPolyfill from 'dialog-polyfill';
// In your app initialization or component
useEffect(() => {
const dialog = dialogRef.current;
if (dialog && typeof HTMLDialogElement === 'undefined') {
dialogPolyfill.registerDialog(dialog);
}
}, []);Other Requirements
- React: 18.0.0 or 19.0.0
- SSR: Safe for server-side rendering (feature detection checks for
window) - Bundle Size: Zero runtime dependencies beyond React
For current browser support statistics, see caniuse.com/dialog.
Contributing
Contributions are welcome! Please:
- Open an issue to discuss proposed changes
- Fork the repository and create a feature branch
- Write tests for new functionality
- Ensure all tests pass:
npm test - Submit a pull request
