Skip to content

Commit 344e8f5

Browse files
committed
feat: add useDebounce hook, integration tests, and state management docs
Closes #482, #489, #490, #496
1 parent d6ecf7d commit 344e8f5

7 files changed

Lines changed: 372 additions & 9 deletions

File tree

docs/state-management.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Client State Management
2+
3+
## The Rule
4+
5+
| Data type | Where it lives |
6+
| ----------------------------------------------------------------------- | ---------------------------------------- |
7+
| Server data (creators, holdings, activity feed) | React Query (`useQuery` / `useMutation`) |
8+
| Ephemeral UI state (modals, input values, selected tabs, loading flags) | Local `useState` |
9+
10+
If the value came from an API response and needs to survive a component unmount or be shared across routes, put it in React Query. If it only controls what the user sees right now and can be re-derived on re-mount, use `useState`.
11+
12+
## Query Invalidation vs Manual Refetch
13+
14+
**Invalidate** after a mutation that changes server data:
15+
16+
```ts
17+
const queryClient = useQueryClient();
18+
queryClient.invalidateQueries({ queryKey: queryKeys.creators.list() });
19+
```
20+
21+
This marks cached data stale and lets React Query refetch in the background the next time the query is observed. Use this after a buy, sell, or profile update so all subscribers see fresh data automatically.
22+
23+
**Refetch manually** only when you need to force an immediate reload independent of staleness — for example, a user-triggered "Refresh" button:
24+
25+
```ts
26+
const { refetch } = useQuery({ queryKey: queryKeys.wallet.holdings(address), ... });
27+
<button onClick={() => refetch()}>Refresh</button>
28+
```
29+
30+
Avoid calling `refetch()` inside effects or after mutations — that bypasses cache coordination and can race with invalidation.
31+
32+
## Do Not Copy Server State into Local State
33+
34+
Storing a React Query result in `useState` breaks cache coherence and causes stale UI after mutations.
35+
36+
### Wrong
37+
38+
```tsx
39+
function CreatorProfile({ id }: { id: string }) {
40+
const { data } = useCreatorDetail(id);
41+
42+
// Never do this — local state diverges from the cache after mutations.
43+
const [creator, setCreator] = useState(data);
44+
45+
return <div>{creator?.title}</div>;
46+
}
47+
```
48+
49+
### Right
50+
51+
```tsx
52+
function CreatorProfile({ id }: { id: string }) {
53+
const { data: creator } = useCreatorDetail(id);
54+
55+
// Read directly from the query result — always in sync with the cache.
56+
return <div>{creator?.title}</div>;
57+
}
58+
```
59+
60+
## Ephemeral UI State Examples
61+
62+
These belong in `useState`, not React Query:
63+
64+
- Modal open/closed: `const [open, setOpen] = useState(false)`
65+
- Controlled input value: `const [query, setQuery] = useState('')`
66+
- Active tab: `const [activeTab, setActiveTab] = useState('overview')`
67+
- Optimistic loading flag: `const [submitting, setSubmitting] = useState(false)`
68+
69+
None of these values need to survive a page navigation or be shared with another component tree, so there is no reason to put them in the server-state layer.

src/components/common/TransactionHistory.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ const TransactionHistory: React.FC = () => {
159159
return (
160160
<div
161161
key={tx.id}
162+
data-testid={`activity-item-${tx.type}`}
162163
className={cn(
163164
'group rounded-xl border border-white/10 bg-white/[0.02] transition-all duration-200 hover:border-white/20 hover:bg-white/[0.04]',
164165
isCompact && !isExpanded && 'py-2',
@@ -183,7 +184,7 @@ const TransactionHistory: React.FC = () => {
183184
<div className="mt-1 flex items-center gap-3 text-xs text-white/50">
184185
<span>{tx.amount} keys</span>
185186
<span className="text-white/30"></span>
186-
<span>{tx.price} ETH</span>
187+
<span>{tx.price} XLM</span>
187188
<span className="text-white/30"></span>
188189
<span>{formatTimestamp(tx.timestamp)}</span>
189190
</div>
@@ -193,9 +194,12 @@ const TransactionHistory: React.FC = () => {
193194
{(!isCompact || isExpanded) && (
194195
<div className="hidden shrink-0 items-center gap-4 text-right sm:flex">
195196
<div className="text-sm">
196-
<div className="font-semibold text-white">
197-
{tx.type === 'buy' ? '+' : '-'}
198-
{(tx.amount * tx.price).toFixed(4)} ETH
197+
<div
198+
className="font-semibold text-white"
199+
data-testid={`tx-amount-${tx.id}`}
200+
>
201+
{tx.type === 'buy' ? '-' : '+'}
202+
{(tx.amount * tx.price).toFixed(4)} XLM
199203
</div>
200204
<div className="text-xs text-white/50">
201205
{tx.txHash}
@@ -213,9 +217,12 @@ const TransactionHistory: React.FC = () => {
213217
{isCompact && !isExpanded && (
214218
<div className="flex shrink-0 items-center gap-3">
215219
<div className="text-right">
216-
<div className="text-sm font-semibold text-white">
217-
{tx.type === 'buy' ? '+' : '-'}
218-
{(tx.amount * tx.price).toFixed(4)} ETH
220+
<div
221+
className="text-sm font-semibold text-white"
222+
data-testid={`tx-amount-${tx.id}`}
223+
>
224+
{tx.type === 'buy' ? '-' : '+'}
225+
{(tx.amount * tx.price).toFixed(4)} XLM
219226
</div>
220227
</div>
221228
<Button
@@ -247,7 +254,7 @@ const TransactionHistory: React.FC = () => {
247254
<div className="flex items-center gap-3 text-white/50">
248255
<span>{tx.amount} keys</span>
249256
<span className="text-white/30"></span>
250-
<span>{tx.price} ETH</span>
257+
<span>{tx.price} XLM</span>
251258
<span className="text-white/30"></span>
252259
<span>{formatTimestamp(tx.timestamp)}</span>
253260
<span className="text-white/30"></span>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it, beforeEach, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import TransactionHistory from '@/components/common/TransactionHistory';
4+
5+
// TransactionHistory reads localStorage during initialisation.
6+
beforeEach(() => {
7+
vi.stubEnv('NODE_ENV', 'test');
8+
localStorage.clear();
9+
});
10+
11+
describe('TransactionHistory – activity feed sign prefix (integration)', () => {
12+
it('buy event amount is prefixed with a minus sign', () => {
13+
render(<TransactionHistory />);
14+
15+
// There is at least one buy activity item in the sample data.
16+
const buyItems = screen.getAllByTestId('activity-item-buy');
17+
expect(buyItems.length).toBeGreaterThan(0);
18+
19+
// For each buy row the visible amount must start with "-".
20+
buyItems.forEach(item => {
21+
const amountEl = item.querySelector('[data-testid^="tx-amount-"]');
22+
expect(amountEl).not.toBeNull();
23+
expect(amountEl!.textContent).toMatch(/^-/);
24+
});
25+
});
26+
27+
it('sell event amount is prefixed with a plus sign', () => {
28+
render(<TransactionHistory />);
29+
30+
const sellItems = screen.getAllByTestId('activity-item-sell');
31+
expect(sellItems.length).toBeGreaterThan(0);
32+
33+
sellItems.forEach(item => {
34+
const amountEl = item.querySelector('[data-testid^="tx-amount-"]');
35+
expect(amountEl).not.toBeNull();
36+
expect(amountEl!.textContent).toMatch(/^\+/);
37+
});
38+
});
39+
40+
it('XLM suffix is present on both buy and sell amounts', () => {
41+
render(<TransactionHistory />);
42+
43+
const allItems = [
44+
...screen.getAllByTestId('activity-item-buy'),
45+
...screen.getAllByTestId('activity-item-sell'),
46+
];
47+
48+
allItems.forEach(item => {
49+
const amountEl = item.querySelector('[data-testid^="tx-amount-"]');
50+
expect(amountEl).not.toBeNull();
51+
expect(amountEl!.textContent).toMatch(/XLM$/);
52+
});
53+
});
54+
});
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
import { useState } from 'react';
4+
import { useDebounce } from '@/hooks/useDebounce';
5+
6+
describe('useDebounce – integration (fake timers)', () => {
7+
beforeEach(() => {
8+
vi.useFakeTimers();
9+
});
10+
11+
afterEach(() => {
12+
vi.useRealTimers();
13+
});
14+
15+
it('does not update the debounced value before the delay elapses', () => {
16+
const { result } = renderHook(() => {
17+
const [value, setValue] = useState('initial');
18+
const debounced = useDebounce(value, 300);
19+
return { value, setValue, debounced };
20+
});
21+
22+
act(() => {
23+
result.current.setValue('updated');
24+
});
25+
26+
// Immediately after the change the debounced value must still be the old one.
27+
expect(result.current.debounced).toBe('initial');
28+
});
29+
30+
it('updates the debounced value after the delay elapses', () => {
31+
const { result } = renderHook(() => {
32+
const [value, setValue] = useState('initial');
33+
const debounced = useDebounce(value, 300);
34+
return { value, setValue, debounced };
35+
});
36+
37+
act(() => {
38+
result.current.setValue('updated');
39+
});
40+
41+
act(() => {
42+
vi.advanceTimersByTime(300);
43+
});
44+
45+
expect(result.current.debounced).toBe('updated');
46+
});
47+
48+
it('resets the timer on each new value during the debounce window', () => {
49+
const { result } = renderHook(() => {
50+
const [value, setValue] = useState('a');
51+
const debounced = useDebounce(value, 300);
52+
return { value, setValue, debounced };
53+
});
54+
55+
act(() => {
56+
result.current.setValue('b');
57+
});
58+
act(() => {
59+
vi.advanceTimersByTime(150);
60+
});
61+
// Still within the window — another update resets the timer.
62+
act(() => {
63+
result.current.setValue('c');
64+
});
65+
act(() => {
66+
vi.advanceTimersByTime(150);
67+
});
68+
// Only 150 ms have passed since the last update, not yet 300 ms.
69+
expect(result.current.debounced).toBe('a');
70+
71+
act(() => {
72+
vi.advanceTimersByTime(150);
73+
});
74+
// Now the full 300 ms have elapsed since the last value change.
75+
expect(result.current.debounced).toBe('c');
76+
});
77+
});

src/hooks/useDebounce.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { useEffect, useState } from 'react';
2+
3+
export function useDebounce<T>(value: T, delayMs: number): T {
4+
const [debounced, setDebounced] = useState<T>(value);
5+
6+
useEffect(() => {
7+
const timer = setTimeout(() => setDebounced(value), delayMs);
8+
return () => clearTimeout(timer);
9+
}, [value, delayMs]);
10+
11+
return debounced;
12+
}

src/pages/LandingPage.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2+
import { useDebounce } from '@/hooks/useDebounce';
23
import { LayoutGroup, motion } from 'framer-motion';
34
import { useSearchParams } from 'react-router';
45
import { courseService, type Course } from '@/services/course.service';
@@ -293,6 +294,7 @@ function LandingPage() {
293294
const [isFilterLoading, setIsFilterLoading] = useState(false);
294295
const [searchParams, setSearchParams] = useSearchParams();
295296
const [searchQuery, setSearchQuery] = useState('');
297+
const debouncedSearchQuery = useDebounce(searchQuery, 300);
296298
const [minPriceFilter, setMinPriceFilter] = useState('');
297299
const [maxPriceFilter, setMaxPriceFilter] = useState('');
298300
const searchQueryRef = useRef<string>('');
@@ -473,6 +475,7 @@ function LandingPage() {
473475
const params = {
474476
...(minPrice !== undefined ? { min_price: minPrice } : {}),
475477
...(maxPrice !== undefined ? { max_price: maxPrice } : {}),
478+
...(debouncedSearchQuery.trim() ? { search: debouncedSearchQuery.trim() } : {}),
476479
};
477480
const data = await courseService.getCourses(
478481
Object.keys(params).length > 0 ? params : undefined
@@ -516,7 +519,7 @@ function LandingPage() {
516519
};
517520

518521
fetchCreators();
519-
}, [fetchRetryAttempt, fetchRequestId, maxPriceFilter, minPriceFilter]);
522+
}, [fetchRetryAttempt, fetchRequestId, maxPriceFilter, minPriceFilter, debouncedSearchQuery]);
520523

521524
const searchSuggestions = useMemo(() => {
522525
const fromCategories = creators

0 commit comments

Comments
 (0)