React Mutations
PGRestify provides powerful mutation hooks for data modification operations in React applications. These hooks handle create, update, delete, and upsert operations with built-in optimistic updates, error handling, and state management.
Basic Mutation Hooks
Generic useMutation Hook
The useMutation
hook provides a flexible interface for any mutation operation:
tsx
import { useMutation, MutationOperation } from '@webcoded/pgrestify/react';
interface User {
id: string;
name: string;
email: string;
created_at: string;
}
function CreateUserForm() {
const {
mutate: createUser,
isLoading,
error,
data: createdUser
} = useMutation<User>('users', {
operation: MutationOperation.INSERT,
onSuccess: (user) => {
console.log('User created:', user);
// Navigate or show success message
},
onError: (error) => {
console.error('Failed to create user:', error);
}
});
const handleSubmit = (formData: FormData) => {
createUser({
name: formData.get('name') as string,
email: formData.get('email') as string
});
};
return (
<form onSubmit={(e) => {
e.preventDefault();
handleSubmit(new FormData(e.currentTarget));
}}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={isLoading}>
{isLoading ? 'Creating...' : 'Create User'}
</button>
{error && <div className="error">Error: {error.message}</div>}
{createdUser && <div className="success">User created successfully!</div>}
</form>
);
}
Insert Operations
Use useInsert
for creating new records:
tsx
interface Post {
id: string;
title: string;
content: string;
author_id: string;
published: boolean;
created_at: string;
}
function CreatePostForm({ authorId }: { authorId: string }) {
const [isDraft, setIsDraft] = useState(true);
const { mutate: createPost, isLoading, error } = useInsert<Post>('posts', {
onSuccess: (post) => {
alert(`Post ${post.published ? 'published' : 'saved as draft'}!`);
// Redirect to post page
window.location.href = `/posts/${post.id}`;
},
onError: (error) => {
console.error('Failed to create post:', error);
}
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
createPost({
title: formData.get('title') as string,
content: formData.get('content') as string,
author_id: authorId,
published: !isDraft
});
};
return (
<form onSubmit={handleSubmit} className="post-form">
<input
name="title"
placeholder="Post Title"
required
className="title-input"
/>
<textarea
name="content"
placeholder="Write your post..."
required
rows={10}
className="content-textarea"
/>
<div className="form-controls">
<label className="draft-toggle">
<input
type="checkbox"
checked={isDraft}
onChange={(e) => setIsDraft(e.target.checked)}
/>
Save as Draft
</label>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : isDraft ? 'Save Draft' : 'Publish'}
</button>
</div>
{error && (
<div className="error">
Failed to {isDraft ? 'save draft' : 'publish'}: {error.message}
</div>
)}
</form>
);
}
Update Operations
Use useUpdate
for modifying existing records:
tsx
interface Profile {
id: string;
user_id: string;
bio?: string;
avatar_url?: string;
website?: string;
updated_at: string;
}
function EditProfileForm({ profile }: { profile: Profile }) {
const [formData, setFormData] = useState({
bio: profile.bio || '',
website: profile.website || ''
});
const { mutate: updateProfile, isLoading, error } = useUpdate<Profile>('profiles', {
onSuccess: (updatedProfile) => {
console.log('Profile updated:', updatedProfile);
alert('Profile updated successfully!');
},
onMutate: (variables) => {
// Optimistic update - update UI immediately
console.log('Optimistically updating with:', variables);
},
onError: (error, variables) => {
console.error('Update failed:', error);
// Revert optimistic updates if needed
}
});
const handleSave = () => {
updateProfile({
data: {
bio: formData.bio,
website: formData.website
},
filter: { id: profile.id }
});
};
const handleInputChange = (field: keyof typeof formData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
return (
<div className="profile-form">
<h2>Edit Profile</h2>
<div className="form-group">
<label>Bio</label>
<textarea
value={formData.bio}
onChange={(e) => handleInputChange('bio', e.target.value)}
placeholder="Tell us about yourself..."
rows={4}
/>
</div>
<div className="form-group">
<label>Website</label>
<input
type="url"
value={formData.website}
onChange={(e) => handleInputChange('website', e.target.value)}
placeholder="https://your-website.com"
/>
</div>
<div className="form-actions">
<button onClick={handleSave} disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
{error && (
<div className="error">
Failed to update profile: {error.message}
</div>
)}
</div>
);
}
Delete Operations
Use useDelete
for removing records:
tsx
function DeleteUserButton({ user }: { user: User }) {
const [showConfirm, setShowConfirm] = useState(false);
const { mutate: deleteUser, isLoading } = useDelete('users', {
onSuccess: () => {
alert('User deleted successfully');
// Navigate away or update parent component
window.location.href = '/users';
},
onError: (error) => {
alert(`Failed to delete user: ${error.message}`);
}
});
const handleDelete = () => {
deleteUser({ id: user.id });
setShowConfirm(false);
};
if (showConfirm) {
return (
<div className="delete-confirmation">
<p>Are you sure you want to delete <strong>{user.name}</strong>?</p>
<p>This action cannot be undone.</p>
<div className="confirmation-actions">
<button
onClick={handleDelete}
disabled={isLoading}
className="danger-button"
>
{isLoading ? 'Deleting...' : 'Yes, Delete'}
</button>
<button
onClick={() => setShowConfirm(false)}
className="cancel-button"
>
Cancel
</button>
</div>
</div>
);
}
return (
<button
onClick={() => setShowConfirm(true)}
className="delete-button"
>
Delete User
</button>
);
}
Upsert Operations
Use useUpsert
for insert-or-update operations:
tsx
interface UserPreference {
user_id: string;
theme: 'light' | 'dark' | 'system';
notifications: boolean;
language: string;
updated_at: string;
}
function UserSettings({ userId }: { userId: string }) {
const [preferences, setPreferences] = useState<Partial<UserPreference>>({
theme: 'system',
notifications: true,
language: 'en'
});
const { mutate: savePreferences, isLoading, error } = useUpsert<UserPreference>('user_preferences', {
onSuccess: (savedPrefs) => {
console.log('Preferences saved:', savedPrefs);
// Show success toast
},
onError: (error) => {
console.error('Failed to save preferences:', error);
}
});
const handleSave = () => {
savePreferences({
user_id: userId,
...preferences
});
};
const updatePreference = <K extends keyof UserPreference>(
key: K,
value: UserPreference[K]
) => {
setPreferences(prev => ({ ...prev, [key]: value }));
};
return (
<div className="user-settings">
<h2>Settings</h2>
<div className="setting-group">
<label>Theme</label>
<select
value={preferences.theme}
onChange={(e) => updatePreference('theme', e.target.value as any)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</div>
<div className="setting-group">
<label>
<input
type="checkbox"
checked={preferences.notifications}
onChange={(e) => updatePreference('notifications', e.target.checked)}
/>
Email Notifications
</label>
</div>
<div className="setting-group">
<label>Language</label>
<select
value={preferences.language}
onChange={(e) => updatePreference('language', e.target.value)}
>
<option value="en">English</option>
<option value="es">Spanish</option>
<option value="fr">French</option>
<option value="de">German</option>
</select>
</div>
<button onClick={handleSave} disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Settings'}
</button>
{error && (
<div className="error">
Failed to save settings: {error.message}
</div>
)}
</div>
);
}
Advanced Mutation Patterns
Optimistic Updates
tsx
function OptimisticLikeButton({ postId, initialLikes }: {
postId: string;
initialLikes: number;
}) {
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(false);
const { mutate: toggleLike, isLoading } = useMutation('post_likes', {
operation: MutationOperation.INSERT,
onMutate: (variables) => {
// Optimistic update
const newLikedState = !isLiked;
const newLikeCount = newLikedState ? likes + 1 : likes - 1;
setIsLiked(newLikedState);
setLikes(newLikeCount);
// Return rollback data
return { previousLiked: isLiked, previousLikes: likes };
},
onError: (error, variables, rollbackData) => {
// Rollback optimistic update
if (rollbackData) {
setIsLiked(rollbackData.previousLiked);
setLikes(rollbackData.previousLikes);
}
},
onSuccess: (data) => {
// Update with server data
setLikes(data.total_likes);
setIsLiked(data.liked);
}
});
const handleToggleLike = () => {
toggleLike({
post_id: postId,
liked: !isLiked
});
};
return (
<button
onClick={handleToggleLike}
disabled={isLoading}
className={`like-button ${isLiked ? 'liked' : ''}`}
>
❤️ {likes} {isLoading && '⟳'}
</button>
);
}
Batch Mutations
tsx
function BulkUserActions({ userIds }: { userIds: string[] }) {
const [selectedAction, setSelectedAction] = useState<'activate' | 'deactivate' | 'delete'>('activate');
const { mutate: bulkUpdate, isLoading, error } = useUpdate<User>('users', {
onSuccess: (result) => {
console.log(`Successfully updated ${userIds.length} users`);
// Refresh user list or navigate
},
onError: (error) => {
console.error('Bulk operation failed:', error);
}
});
const handleBulkAction = () => {
const updateData: Partial<User> = {};
switch (selectedAction) {
case 'activate':
updateData.active = true;
break;
case 'deactivate':
updateData.active = false;
break;
case 'delete':
// Use delete mutation for this case
break;
}
bulkUpdate({
data: updateData,
filter: {
id: `in.(${userIds.join(',')})` // PostgREST syntax for IN operator
}
});
};
return (
<div className="bulk-actions">
<div className="action-controls">
<select
value={selectedAction}
onChange={(e) => setSelectedAction(e.target.value as any)}
>
<option value="activate">Activate</option>
<option value="deactivate">Deactivate</option>
<option value="delete">Delete</option>
</select>
<button
onClick={handleBulkAction}
disabled={isLoading || userIds.length === 0}
className={selectedAction === 'delete' ? 'danger-button' : ''}
>
{isLoading ? 'Processing...' : `${selectedAction} ${userIds.length} Users`}
</button>
</div>
{error && (
<div className="error">
Bulk operation failed: {error.message}
</div>
)}
</div>
);
}
Mutation with File Upload
tsx
function AvatarUpload({ userId }: { userId: string }) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string>('');
const { mutate: uploadAvatar, isLoading, error } = useUpdate<Profile>('profiles', {
onSuccess: (profile) => {
console.log('Avatar updated:', profile.avatar_url);
setSelectedFile(null);
setPreview('');
}
});
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
setPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
}
};
const handleUpload = async () => {
if (!selectedFile) return;
try {
// Upload file to storage service (e.g., Supabase Storage, AWS S3)
const fileUrl = await uploadFileToStorage(selectedFile);
// Update profile with new avatar URL
uploadAvatar({
data: { avatar_url: fileUrl },
filter: { user_id: userId }
});
} catch (error) {
console.error('File upload failed:', error);
}
};
return (
<div className="avatar-upload">
<div className="upload-area">
{preview ? (
<img src={preview} alt="Avatar preview" className="avatar-preview" />
) : (
<div className="upload-placeholder">
<p>Select an image</p>
</div>
)}
<input
type="file"
accept="image/*"
onChange={handleFileSelect}
className="file-input"
/>
</div>
<div className="upload-actions">
<button
onClick={handleUpload}
disabled={!selectedFile || isLoading}
>
{isLoading ? 'UpisLoading...' : 'Upload Avatar'}
</button>
{selectedFile && (
<button
onClick={() => {
setSelectedFile(null);
setPreview('');
}}
className="cancel-button"
>
Cancel
</button>
)}
</div>
{error && (
<div className="error">
Upload failed: {error.message}
</div>
)}
</div>
);
}
// Utility function (implement based on your storage solution)
async function uploadFileToStorage(file: File): Promise<string> {
// Example implementation for Supabase Storage
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
const { url } = await response.json();
return url;
}
Form Integration
React Hook Form Integration
tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// Validation schema
const userSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().min(18, 'Must be at least 18 years old').optional()
});
type UserFormData = z.infer<typeof userSchema>;
function UserForm({ user, onSuccess }: {
user?: User;
onSuccess?: (user: User) => void;
}) {
const isEditing = !!user;
const {
register,
handleSubmit,
formState: { errors, isDirty },
reset
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
name: user?.name || '',
email: user?.email || '',
age: user?.age || undefined
}
});
const createMutation = useInsert<User>('users', {
onSuccess: (newUser) => {
reset();
onSuccess?.(newUser);
}
});
const updateMutation = useUpdate<User>('users', {
onSuccess: (updatedUser) => {
reset(updatedUser);
onSuccess?.(updatedUser);
}
});
const onSubmit = (data: UserFormData) => {
if (isEditing) {
updateMutation.mutate({
data,
filter: { id: user!.id }
});
} else {
createMutation.mutate(data);
}
};
const isLoading = createMutation.isLoading || updateMutation.isLoading;
const error = createMutation.error || updateMutation.error;
return (
<form onSubmit={handleSubmit(onSubmit)} className="user-form">
<div className="form-group">
<label>Name</label>
<input
{...register('name')}
className={errors.name ? 'error' : ''}
/>
{errors.name && (
<span className="error-message">{errors.name.message}</span>
)}
</div>
<div className="form-group">
<label>Email</label>
<input
type="email"
{...register('email')}
className={errors.email ? 'error' : ''}
/>
{errors.email && (
<span className="error-message">{errors.email.message}</span>
)}
</div>
<div className="form-group">
<label>Age (optional)</label>
<input
type="number"
{...register('age', { valueAsNumber: true })}
className={errors.age ? 'error' : ''}
/>
{errors.age && (
<span className="error-message">{errors.age.message}</span>
)}
</div>
<div className="form-actions">
<button
type="submit"
disabled={isLoading || !isDirty}
>
{isLoading
? 'Saving...'
: isEditing
? 'Update User'
: 'Create User'
}
</button>
{isEditing && (
<button
type="button"
onClick={() => reset()}
disabled={isLoading}
>
Reset
</button>
)}
</div>
{error && (
<div className="error-banner">
{isEditing ? 'Update' : 'Create'} failed: {error.message}
</div>
)}
</form>
);
}
Formik Integration
tsx
import { Formik, Form, Field, ErrorMessage } from 'formik';
import * as Yup from 'yup';
const validationSchema = Yup.object({
title: Yup.string().required('Title is required'),
content: Yup.string().required('Content is required'),
tags: Yup.array().of(Yup.string())
});
function PostForm({ post, onSuccess }: {
post?: Post;
onSuccess?: (post: Post) => void;
}) {
const isEditing = !!post;
const { mutate: savePost, isLoading, error } = useMutation<Post>('posts', {
operation: isEditing ? MutationOperation.UPDATE : MutationOperation.INSERT,
onSuccess: (savedPost) => {
console.log('Post saved:', savedPost);
onSuccess?.(savedPost);
}
});
const initialValues = {
title: post?.title || '',
content: post?.content || '',
tags: post?.tags || []
};
return (
<Formik
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting, resetForm }) => {
if (isEditing) {
savePost({
data: values,
filter: { id: post!.id }
});
} else {
savePost(values);
}
setSubmitting(false);
}}
>
{({ isSubmitting, dirty }) => (
<Form className="post-form">
<div className="form-group">
<label>Title</label>
<Field name="title" className="form-control" />
<ErrorMessage name="title" component="div" className="error" />
</div>
<div className="form-group">
<label>Content</label>
<Field as="textarea" name="content" rows={8} className="form-control" />
<ErrorMessage name="content" component="div" className="error" />
</div>
<button
type="submit"
disabled={isLoading || isSubmitting || !dirty}
>
{isLoading ? 'Saving...' : isEditing ? 'Update Post' : 'Create Post'}
</button>
{error && (
<div className="error-banner">
Save failed: {error.message}
</div>
)}
</Form>
)}
</Formik>
);
}
Error Handling and Recovery
Retry Logic
tsx
function ReliableMutation() {
const { mutate, isLoading, error, retry } = useMutation<User>('users', {
operation: MutationOperation.INSERT,
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
onError: (error, variables, context) => {
console.error(`Mutation failed (attempt ${context?.attempt || 1}):`, error);
}
});
return (
<div>
<button onClick={() => mutate({ name: 'Test', email: 'test@example.com' })}>
Create User
</button>
{isLoading && <div>Creating user...</div>}
{error && (
<div className="error">
<p>Failed to create user: {error.message}</p>
<button onClick={retry}>Try Again</button>
</div>
)}
</div>
);
}
Global Error Handler
tsx
function GlobalMutationErrorHandler({ children }: { children: React.ReactNode }) {
const handleMutationError = useCallback((error: Error, context: any) => {
console.error('Mutation error:', error, context);
// Show global notification
if (error.message.includes('network')) {
showNotification('Network error. Please check your connection.', 'error');
} else if (error.message.includes('unauthorized')) {
showNotification('Session expired. Please log in again.', 'warning');
// Redirect to login
} else {
showNotification('Something went wrong. Please try again.', 'error');
}
}, []);
return (
<MutationErrorProvider onError={handleMutationError}>
{children}
</MutationErrorProvider>
);
}
Best Practices
1. Optimistic Updates
tsx
// Good: Implement optimistic updates for better UX
const { mutate: updatePost } = useUpdate<Post>('posts', {
onMutate: (variables) => {
// Update UI immediately
setPost(current => ({ ...current, ...variables.data }));
return { previousPost: post };
},
onError: (error, variables, rollbackData) => {
// Rollback on error
setPost(rollbackData.previousPost);
}
});
2. Mutation State Management
tsx
// Good: Centralize mutation state
function useMutationState() {
const [mutations, setMutations] = useState<Map<string, MutationState>>(new Map());
const registerMutation = (key: string, state: MutationState) => {
setMutations(prev => new Map(prev.set(key, state)));
};
const isAnyLoading = Array.from(mutations.values()).some(m => m.isLoading);
return { mutations, registerMutation, isAnyLoading };
}
3. Form State Sync
tsx
// Good: Sync form state with server state
function EditForm({ initialData }: { initialData: User }) {
const [formData, setFormData] = useState(initialData);
const { mutate, isLoading, error } = useUpdate<User>('users', {
onSuccess: (updatedUser) => {
setFormData(updatedUser); // Sync with server response
}
});
// Reset form when initial data changes
useEffect(() => {
setFormData(initialData);
}, [initialData]);
}
Summary
PGRestify's React mutation hooks provide:
- Type-Safe Operations: Full TypeScript support for all mutation types
- Optimistic Updates: Built-in support for immediate UI updates
- Error Recovery: Automatic retry logic and rollback capabilities
- Form Integration: Seamless integration with popular form libraries
- Flexible API: Support for simple operations to complex batch mutations
- Performance: Optimized mutations with minimal re-renders
These mutation patterns enable building responsive, user-friendly React applications with robust data modification capabilities.