Skip to main content

Soft Deletes

Hooks for the full soft-delete lifecycle — list trashed items, restore them, or permanently delete.

useModelTrashed(model, options?, queryOptions?)​

Fetch soft-deleted records. Accepts the same query options as useModelIndex (filters, sorts, pagination, search, includes, scope), and the same trailing queryOptions.

src/hooks/useModelTrashed.ts
import { useModelTrashed } from '@rhino-dev/rhino-react';

const { data: response, isLoading } = useModelTrashed('posts', {
sort: '-deleted_at',
page: 1,
perPage: 20,
includes: ['user'],
});

const trashedPosts = response?.data || [];
const pagination = response?.pagination;

API Request: GET /api/{organization}/posts/trashed?sort=-deleted_at&page=1&per_page=20&include=user

useModelRestore(model, mutationOptions?)​

Restore a soft-deleted record. Clears the deleted_at timestamp and makes the record visible again. On success it invalidates the model's index, infinite, trashed and show queries. The optional mutationOptions take any useMutation option except mutationFn.

src/hooks/useModelRestore.ts
import { useModelRestore } from '@rhino-dev/rhino-react';

const restore = useModelRestore('posts');

restore.mutate(postId, {
onSuccess: (restoredPost) => {
console.log('Restored:', restoredPost);
// restoredPost.deleted_at is now null
},
onError: (error) => {
console.error('Restore failed:', error);
},
});

API Request: POST /api/{organization}/posts/{id}/restore

useModelForceDelete(model, mutationOptions?)​

Permanently delete a record from the database. This cannot be undone. On success it invalidates the model's trashed queries. The optional mutationOptions take any useMutation option except mutationFn.

src/hooks/useModelForceDelete.ts
import { useModelForceDelete } from '@rhino-dev/rhino-react';

const forceDelete = useModelForceDelete('posts');

forceDelete.mutate(postId, {
onSuccess: () => {
console.log('Permanently deleted');
},
});

API Request: DELETE /api/{organization}/posts/{id}/force-delete

Permanent Action

Force delete removes the record from the database entirely. There is no way to recover it. Always use a confirmation dialog.

Complete Trash Manager Component​

A full trash management interface with restore and permanent delete:

src/components/TrashManager.tsx
import { useState } from 'react';
import {
useModelTrashed,
useModelRestore,
useModelForceDelete,
} from '@rhino-dev/rhino-react';

function TrashManager() {
const [page, setPage] = useState(1);

// Fetch trashed posts
const { data: response, isLoading, refetch } = useModelTrashed('posts', {
sort: '-deleted_at',
page,
perPage: 10,
includes: ['user'],
});

// Mutations
const restore = useModelRestore('posts');
const forceDelete = useModelForceDelete('posts');

const trashedPosts = response?.data || [];
const pagination = response?.pagination;

const handleRestore = (id) => {
restore.mutate(id, {
onSuccess: () => refetch(),
});
};

const handleForceDelete = (id) => {
if (window.confirm('Permanently delete this post? This cannot be undone.')) {
forceDelete.mutate(id, {
onSuccess: () => refetch(),
});
}
};

if (isLoading) return <div>Loading trash...</div>;

return (
<div>
<h2>Trash ({pagination?.total || 0} items)</h2>

{trashedPosts.length === 0 ? (
<p>Trash is empty.</p>
) : (
<table>
<thead>
<tr>
<th>Title</th>
<th>Deleted By</th>
<th>Deleted At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{trashedPosts.map((post) => (
<tr key={post.id}>
<td>{post.title}</td>
<td>{post.user?.name}</td>
<td>{new Date(post.deleted_at).toLocaleDateString()}</td>
<td>
<button
onClick={() => handleRestore(post.id)}
disabled={restore.isPending}
>
{restore.isPending ? 'Restoring...' : 'Restore'}
</button>
<button
onClick={() => handleForceDelete(post.id)}
disabled={forceDelete.isPending}
style={{ color: 'red', marginLeft: '8px' }}
>
{forceDelete.isPending ? 'Deleting...' : 'Delete Forever'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}

{/* Pagination */}
{pagination && pagination.lastPage > 1 && (
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center' }}>
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
Previous
</button>
<span>
Page {pagination.currentPage} of {pagination.lastPage}
</span>
<button
onClick={() => setPage((p) => Math.min(pagination.lastPage, p + 1))}
disabled={page >= pagination.lastPage}
>
Next
</button>
</div>
)}
</div>
);
}

Integration with Regular Delete​

The regular useModelDelete hook performs a soft delete (moves to trash). Combine it with the trash hooks for a complete workflow:

src/components/PostActions.tsx
import { useModelDelete, useModelTrashed, useModelRestore } from '@rhino-dev/rhino-react';

function PostActions({ post }) {
const softDelete = useModelDelete('posts');
const restore = useModelRestore('posts');

return (
<div>
{post.deleted_at ? (
// Post is in trash — show restore
<button onClick={() => restore.mutate(post.id)}>
Restore
</button>
) : (
// Post is active — show delete
<button onClick={() => softDelete.mutate(post.id)}>
Move to Trash
</button>
)}
</div>
);
}
Tab-based Interface

A common pattern is to show active items and trashed items in separate tabs:

src/pages/PostsPage.tsx
function PostsPage() {
const [tab, setTab] = useState('active');

return (
<div>
<button onClick={() => setTab('active')}>Active</button>
<button onClick={() => setTab('trash')}>Trash</button>

{tab === 'active' ? <PostsList /> : <TrashManager />}
</div>
);
}