|
1 | 1 | import { |
| 2 | + useClickOutside, |
2 | 3 | useDebounceCallback, |
3 | 4 | useDisclosure, |
4 | 5 | useMutation, |
5 | 6 | useOptimistic, |
6 | 7 | useQuery |
7 | 8 | } from '@siberiacancode/reactuse'; |
8 | | -import { HeartIcon, XIcon } from 'lucide-react'; |
9 | | -import { createPortal } from 'react-dom'; |
| 9 | +import { HeartIcon, MoreHorizontalIcon, PinIcon, Trash2Icon, Undo2Icon } from 'lucide-react'; |
| 10 | +import { useState } from 'react'; |
10 | 11 |
|
11 | 12 | import { cn } from '@/utils/lib'; |
12 | 13 |
|
13 | | -const POST = { |
14 | | - name: 'reactuse', |
15 | | - handle: '@reactuse', |
16 | | - time: '2h', |
17 | | - text: 'Like this post to see optimistic updates in action — the UI reacts instantly, but every 5th request fails and rolls back.', |
| 14 | +interface Post { |
| 15 | + handle: string; |
| 16 | + id: string; |
| 17 | + liked: boolean; |
| 18 | + likes: number; |
| 19 | + logo: string; |
| 20 | + name: string; |
| 21 | + pinned: boolean; |
| 22 | + text: string; |
| 23 | + time: string; |
| 24 | +} |
| 25 | + |
| 26 | +const REGULAR_POST: Post = { |
| 27 | + id: 'regular', |
| 28 | + name: 'React', |
| 29 | + handle: '@reactjs', |
| 30 | + logo: 'https://cdn.simpleicons.org/react', |
| 31 | + text: 'Try liking or deleting this post — changes appear instantly, then sync or roll back.', |
| 32 | + time: '5h', |
| 33 | + pinned: false, |
18 | 34 | liked: false, |
19 | | - likes: 12 |
| 35 | + likes: 42 |
20 | 36 | }; |
21 | 37 |
|
22 | | -type Post = typeof POST; |
| 38 | +let POSTS: Post[] = [ |
| 39 | + { |
| 40 | + id: 'pinned', |
| 41 | + name: 'reactuse', |
| 42 | + handle: '@reactuse', |
| 43 | + logo: 'https://reactuse.org/logo.svg', |
| 44 | + text: 'Welcome to reactuse — a collection of essential React hooks. This post is pinned and cannot be deleted.', |
| 45 | + time: '2h', |
| 46 | + pinned: true, |
| 47 | + liked: false, |
| 48 | + likes: 128 |
| 49 | + }, |
| 50 | + { ...REGULAR_POST } |
| 51 | +]; |
| 52 | + |
| 53 | +const fetchPosts = () => |
| 54 | + new Promise<Post[]>((resolve) => |
| 55 | + setTimeout(() => resolve(POSTS.map((post) => ({ ...post }))), 500) |
| 56 | + ); |
23 | 57 |
|
24 | | -let attempt = 0; |
| 58 | +const likePost = (id: string, liked: boolean) => |
| 59 | + new Promise<void>((resolve) => |
| 60 | + setTimeout(() => { |
| 61 | + const post = POSTS.find((item) => item.id === id); |
| 62 | + if (post) { |
| 63 | + post.liked = liked; |
| 64 | + post.likes += liked ? 1 : -1; |
| 65 | + } |
| 66 | + resolve(); |
| 67 | + }, 500) |
| 68 | + ); |
25 | 69 |
|
26 | | -// eslint-disable-next-line e18e/prefer-timer-args |
27 | | -const fetchPost = () => new Promise<Post>((resolve) => setTimeout(() => resolve({ ...POST }), 500)); |
| 70 | +const deletePost = (id: string) => |
| 71 | + new Promise<void>((resolve) => |
| 72 | + setTimeout(() => { |
| 73 | + POSTS = POSTS.filter((post) => post.id !== id); |
| 74 | + resolve(); |
| 75 | + }, 800) |
| 76 | + ); |
28 | 77 |
|
29 | | -const toggleLike = (next: boolean) => |
30 | | - new Promise<Post>((resolve, reject) => { |
| 78 | +const restorePost = () => |
| 79 | + new Promise<void>((resolve) => |
31 | 80 | setTimeout(() => { |
32 | | - attempt += 1; |
33 | | - if (attempt % 5 === 0) { |
34 | | - reject(new Error('Network error')); |
35 | | - return; |
36 | | - } |
37 | | - POST.liked = next; |
38 | | - POST.likes = 12 + (next ? 1 : 0); |
39 | | - resolve({ ...POST }); |
40 | | - }, 500); |
41 | | - }); |
| 81 | + POSTS = [...POSTS, { ...REGULAR_POST }]; |
| 82 | + resolve(); |
| 83 | + }, 800) |
| 84 | + ); |
42 | 85 |
|
43 | | -const Demo = () => { |
44 | | - const postQuery = useQuery(fetchPost, { enabled: false, placeholderData: POST }); |
45 | | - const toggleLikeMutation = useMutation(toggleLike); |
| 86 | +interface PostCardProps { |
| 87 | + post: Post; |
| 88 | + onDelete: (id: string) => void; |
| 89 | + onLike: (post: Post) => void; |
| 90 | +} |
| 91 | + |
| 92 | +const PostCard = ({ post, onLike, onDelete }: PostCardProps) => { |
| 93 | + const menu = useDisclosure(); |
| 94 | + const ref = useClickOutside<HTMLDivElement>(() => menu.close()); |
46 | 95 |
|
47 | | - const data = postQuery.data!; |
| 96 | + return ( |
| 97 | + <article className='border-border flex flex-col gap-2 border-b py-3 last:border-b-0'> |
| 98 | + <div className='flex items-center gap-2'> |
| 99 | + <div className='bg-muted flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full'> |
| 100 | + <img alt={post.name} className='size-5' src={post.logo} /> |
| 101 | + </div> |
| 102 | + <span className='text-foreground text-sm font-semibold'>{post.name}</span> |
| 103 | + <span className='text-muted-foreground text-xs'>{post.handle}</span> |
| 104 | + <span className='text-muted-foreground text-xs'>· {post.time}</span> |
| 105 | + |
| 106 | + {post.pinned && <PinIcon className='text-muted-foreground ml-auto size-4 fill-current' />} |
| 107 | + |
| 108 | + {!post.pinned && ( |
| 109 | + <div className='relative ml-auto'> |
| 110 | + <button |
| 111 | + aria-label='More' |
| 112 | + className='text-muted-foreground hover:text-foreground' |
| 113 | + data-size='icon-sm' |
| 114 | + data-variant='ghost' |
| 115 | + type='button' |
| 116 | + onClick={menu.toggle} |
| 117 | + > |
| 118 | + <MoreHorizontalIcon className='size-4' /> |
| 119 | + </button> |
| 120 | + |
| 121 | + {menu.opened && ( |
| 122 | + <div |
| 123 | + ref={ref} |
| 124 | + className='absolute top-full right-0 mt-1' |
| 125 | + data-slot='dropdown-menu-content' |
| 126 | + > |
| 127 | + <div |
| 128 | + data-slot='dropdown-menu-item' |
| 129 | + data-variant='destructive' |
| 130 | + onClick={() => { |
| 131 | + menu.close(); |
| 132 | + onDelete(post.id); |
| 133 | + }} |
| 134 | + > |
| 135 | + <Trash2Icon /> |
| 136 | + Delete |
| 137 | + </div> |
| 138 | + </div> |
| 139 | + )} |
| 140 | + </div> |
| 141 | + )} |
| 142 | + </div> |
| 143 | + |
| 144 | + <p className='text-foreground text-sm leading-relaxed'>{post.text}</p> |
| 145 | + |
| 146 | + <button |
| 147 | + className={cn( |
| 148 | + 'flex w-fit items-center gap-1.5 px-0! text-sm transition-colors', |
| 149 | + post.liked ? 'text-destructive' : 'text-muted-foreground hover:text-destructive' |
| 150 | + )} |
| 151 | + data-variant='unstyled' |
| 152 | + type='button' |
| 153 | + onClick={() => onLike(post)} |
| 154 | + > |
| 155 | + <HeartIcon className='size-4' fill={post.liked ? 'currentColor' : 'none'} /> |
| 156 | + {post.likes} |
| 157 | + </button> |
| 158 | + </article> |
| 159 | + ); |
| 160 | +}; |
48 | 161 |
|
49 | | - const toast = useDisclosure(); |
50 | | - const [optimisticLiked, updateOptimistic, setOptimisticLiked] = useOptimistic( |
51 | | - data.liked, |
52 | | - (_, value: boolean) => value |
| 162 | +const Demo = () => { |
| 163 | + const postsQuery = useQuery(fetchPosts, { enabled: false, placeholderData: POSTS }); |
| 164 | + const likeMutation = useMutation(({ id, liked }: { id: string; liked: boolean }) => |
| 165 | + likePost(id, liked) |
53 | 166 | ); |
| 167 | + const deleteMutation = useMutation(deletePost); |
| 168 | + const restoreMutation = useMutation(restorePost); |
| 169 | + |
| 170 | + const data = postsQuery.data!; |
54 | 171 |
|
55 | | - const commit = useDebounceCallback((next: boolean) => { |
56 | | - if (next === data.liked) return; |
| 172 | + const [posts, updateOptimistic, setPosts] = useOptimistic(data, (_, value) => value); |
57 | 173 |
|
| 174 | + const [deletedIds, setDeletedIds] = useState<string[]>([]); |
| 175 | + |
| 176 | + const likeCommit = useDebounceCallback((id: string, liked: boolean, optimistic: Post[]) => { |
58 | 177 | const promise = (async () => { |
59 | | - try { |
60 | | - await toggleLikeMutation.mutateAsync(next); |
61 | | - await postQuery.refetch(); |
62 | | - } catch { |
63 | | - toast.open(); |
64 | | - setTimeout(toast.close, 1500); |
65 | | - throw new Error('Network error'); |
66 | | - } |
| 178 | + await likeMutation.mutateAsync({ id, liked }); |
| 179 | + await postsQuery.fetch(); |
67 | 180 | })(); |
68 | | - |
69 | | - updateOptimistic(next, promise); |
| 181 | + updateOptimistic(optimistic, promise); |
70 | 182 | }, 600); |
71 | 183 |
|
72 | | - const onLike = () => { |
73 | | - const next = !optimisticLiked; |
74 | | - setOptimisticLiked(next); |
75 | | - commit(next); |
| 184 | + const onLike = (target: Post) => { |
| 185 | + const liked = !target.liked; |
| 186 | + const optimistic = posts.map((item) => |
| 187 | + item.id === target.id ? { ...item, liked, likes: item.likes + (liked ? 1 : -1) } : item |
| 188 | + ); |
| 189 | + setPosts(optimistic); |
| 190 | + likeCommit(target.id, liked, optimistic); |
76 | 191 | }; |
77 | 192 |
|
78 | | - const likeCount = data.likes + (optimisticLiked === data.liked ? 0 : optimisticLiked ? 1 : -1); |
| 193 | + const onDelete = (id: string) => { |
| 194 | + setDeletedIds((current) => [...current, id]); |
79 | 195 |
|
80 | | - return ( |
81 | | - <section className='relative flex w-full max-w-md flex-col py-4'> |
82 | | - <article className='flex flex-col gap-3'> |
83 | | - <div className='flex items-center gap-2'> |
84 | | - <div className='bg-muted flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full'> |
85 | | - <img alt='reactuse' className='size-6' src='https://reactuse.org/logo.svg' /> |
86 | | - </div> |
87 | | - <span className='text-foreground text-sm font-semibold'>{data.name}</span> |
88 | | - <span className='text-muted-foreground text-xs'>{data.handle}</span> |
89 | | - <span className='text-muted-foreground text-xs'>· {data.time}</span> |
90 | | - </div> |
| 196 | + const promise = (async () => { |
| 197 | + await deleteMutation.mutateAsync(id); |
| 198 | + await postsQuery.fetch(); |
| 199 | + })(); |
| 200 | + updateOptimistic( |
| 201 | + posts.filter((item) => item.id !== id), |
| 202 | + promise |
| 203 | + ); |
| 204 | + }; |
91 | 205 |
|
92 | | - <p className='text-foreground text-sm leading-relaxed'>{data.text}</p> |
| 206 | + const onUndo = () => { |
| 207 | + setDeletedIds([]); |
93 | 208 |
|
94 | | - <div className='flex items-center'> |
| 209 | + const promise = (async () => { |
| 210 | + await restoreMutation.mutateAsync(); |
| 211 | + await postsQuery.fetch(); |
| 212 | + })(); |
| 213 | + updateOptimistic([...posts, { ...REGULAR_POST }], promise); |
| 214 | + }; |
| 215 | + |
| 216 | + return ( |
| 217 | + <section className='flex w-full max-w-md flex-col py-4'> |
| 218 | + {posts.map((item) => ( |
| 219 | + <PostCard key={item.id} post={item} onDelete={onDelete} onLike={onLike} /> |
| 220 | + ))} |
| 221 | + |
| 222 | + {!!deletedIds.length && ( |
| 223 | + <div className='flex items-center justify-between pt-3'> |
| 224 | + <span className='text-muted-foreground text-sm'>Post deleted</span> |
95 | 225 | <button |
96 | | - className={cn( |
97 | | - 'flex items-center gap-1.5 px-0! text-sm transition-colors', |
98 | | - optimisticLiked ? 'text-destructive' : 'text-muted-foreground hover:text-destructive' |
99 | | - )} |
100 | | - data-variant='unstyled' |
| 226 | + data-size='sm' |
| 227 | + data-variant='ghost' |
| 228 | + disabled={restoreMutation.isLoading} |
101 | 229 | type='button' |
102 | | - onClick={onLike} |
| 230 | + onClick={onUndo} |
103 | 231 | > |
104 | | - <HeartIcon className='size-4' fill={optimisticLiked ? 'currentColor' : 'none'} /> |
105 | | - {likeCount} |
| 232 | + <Undo2Icon className='size-3.5' /> |
| 233 | + Undo |
106 | 234 | </button> |
107 | 235 | </div> |
108 | | - </article> |
109 | | - |
110 | | - {toast.opened && |
111 | | - createPortal( |
112 | | - <div className='animate-in fade-in slide-in-from-bottom-4 fixed right-4 bottom-6 left-4 flex items-center gap-3 rounded-2xl border border-black/5 bg-white px-4 py-3.5 text-sm font-medium text-gray-900 shadow-xl duration-300 sm:right-6 sm:left-auto sm:w-auto sm:min-w-72 dark:border-white/10 dark:bg-neutral-900 dark:text-white'> |
113 | | - <div className='bg-destructive flex size-6 shrink-0 items-center justify-center rounded-full'> |
114 | | - <XIcon className='size-3.5 text-white' strokeWidth={3} /> |
115 | | - </div> |
116 | | - Failed to change like |
117 | | - </div>, |
118 | | - document.body |
119 | | - )} |
| 236 | + )} |
120 | 237 | </section> |
121 | 238 | ); |
122 | 239 | }; |
|
0 commit comments