Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions docs/state-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Client State Management

## The Rule

| Data type | Where it lives |
| ----------------------------------------------------------------------- | ---------------------------------------- |
| Server data (creators, holdings, activity feed) | React Query (`useQuery` / `useMutation`) |
| Ephemeral UI state (modals, input values, selected tabs, loading flags) | Local `useState` |

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`.

## Query Invalidation vs Manual Refetch

**Invalidate** after a mutation that changes server data:

```ts
const queryClient = useQueryClient();
queryClient.invalidateQueries({ queryKey: queryKeys.creators.list() });
```

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.

**Refetch manually** only when you need to force an immediate reload independent of staleness — for example, a user-triggered "Refresh" button:

```ts
const { refetch } = useQuery({ queryKey: queryKeys.wallet.holdings(address), ... });
<button onClick={() => refetch()}>Refresh</button>
```

Avoid calling `refetch()` inside effects or after mutations — that bypasses cache coordination and can race with invalidation.

## Do Not Copy Server State into Local State

Storing a React Query result in `useState` breaks cache coherence and causes stale UI after mutations.

### Wrong

```tsx
function CreatorProfile({ id }: { id: string }) {
const { data } = useCreatorDetail(id);

// Never do this — local state diverges from the cache after mutations.
const [creator, setCreator] = useState(data);

return <div>{creator?.title}</div>;
}
```

### Right

```tsx
function CreatorProfile({ id }: { id: string }) {
const { data: creator } = useCreatorDetail(id);

// Read directly from the query result — always in sync with the cache.
return <div>{creator?.title}</div>;
}
```

## Ephemeral UI State Examples

These belong in `useState`, not React Query:

- Modal open/closed: `const [open, setOpen] = useState(false)`
- Controlled input value: `const [query, setQuery] = useState('')`
- Active tab: `const [activeTab, setActiveTab] = useState('overview')`
- Optimistic loading flag: `const [submitting, setSubmitting] = useState(false)`

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.
23 changes: 15 additions & 8 deletions src/components/common/TransactionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ const TransactionHistory: React.FC = () => {
return (
<div
key={tx.id}
data-testid={`activity-item-${tx.type}`}
className={cn(
'group rounded-xl border border-white/10 bg-white/[0.02] transition-all duration-200 hover:border-white/20 hover:bg-white/[0.04]',
isCompact && !isExpanded && 'py-2',
Expand All @@ -183,7 +184,7 @@ const TransactionHistory: React.FC = () => {
<div className="mt-1 flex items-center gap-3 text-xs text-white/50">
<span>{tx.amount} keys</span>
<span className="text-white/30">•</span>
<span>{tx.price} ETH</span>
<span>{tx.price} XLM</span>
<span className="text-white/30">•</span>
<span>{formatTimestamp(tx.timestamp)}</span>
</div>
Expand All @@ -193,9 +194,12 @@ const TransactionHistory: React.FC = () => {
{(!isCompact || isExpanded) && (
<div className="hidden shrink-0 items-center gap-4 text-right sm:flex">
<div className="text-sm">
<div className="font-semibold text-white">
{tx.type === 'buy' ? '+' : '-'}
{(tx.amount * tx.price).toFixed(4)} ETH
<div
className="font-semibold text-white"
data-testid={`tx-amount-${tx.id}`}
>
{tx.type === 'buy' ? '-' : '+'}
{(tx.amount * tx.price).toFixed(4)} XLM
</div>
<div className="text-xs text-white/50">
{tx.txHash}
Expand All @@ -213,9 +217,12 @@ const TransactionHistory: React.FC = () => {
{isCompact && !isExpanded && (
<div className="flex shrink-0 items-center gap-3">
<div className="text-right">
<div className="text-sm font-semibold text-white">
{tx.type === 'buy' ? '+' : '-'}
{(tx.amount * tx.price).toFixed(4)} ETH
<div
className="text-sm font-semibold text-white"
data-testid={`tx-amount-${tx.id}`}
>
{tx.type === 'buy' ? '-' : '+'}
{(tx.amount * tx.price).toFixed(4)} XLM
</div>
</div>
<Button
Expand Down Expand Up @@ -247,7 +254,7 @@ const TransactionHistory: React.FC = () => {
<div className="flex items-center gap-3 text-white/50">
<span>{tx.amount} keys</span>
<span className="text-white/30">•</span>
<span>{tx.price} ETH</span>
<span>{tx.price} XLM</span>
<span className="text-white/30">•</span>
<span>{formatTimestamp(tx.timestamp)}</span>
<span className="text-white/30">•</span>
Expand Down
54 changes: 54 additions & 0 deletions src/components/common/__tests__/TransactionHistory.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import TransactionHistory from '@/components/common/TransactionHistory';

// TransactionHistory reads localStorage during initialisation.
beforeEach(() => {
vi.stubEnv('NODE_ENV', 'test');
localStorage.clear();
});

describe('TransactionHistory – activity feed sign prefix (integration)', () => {
it('buy event amount is prefixed with a minus sign', () => {
render(<TransactionHistory />);

// There is at least one buy activity item in the sample data.
const buyItems = screen.getAllByTestId('activity-item-buy');
expect(buyItems.length).toBeGreaterThan(0);

// For each buy row the visible amount must start with "-".
buyItems.forEach(item => {
const amountEl = item.querySelector('[data-testid^="tx-amount-"]');
expect(amountEl).not.toBeNull();
expect(amountEl!.textContent).toMatch(/^-/);
});
});

it('sell event amount is prefixed with a plus sign', () => {
render(<TransactionHistory />);

const sellItems = screen.getAllByTestId('activity-item-sell');
expect(sellItems.length).toBeGreaterThan(0);

sellItems.forEach(item => {
const amountEl = item.querySelector('[data-testid^="tx-amount-"]');
expect(amountEl).not.toBeNull();
expect(amountEl!.textContent).toMatch(/^\+/);
});
});

it('XLM suffix is present on both buy and sell amounts', () => {
render(<TransactionHistory />);

const allItems = [
...screen.getAllByTestId('activity-item-buy'),
...screen.getAllByTestId('activity-item-sell'),
];

allItems.forEach(item => {
const amountEl = item.querySelector('[data-testid^="tx-amount-"]');
expect(amountEl).not.toBeNull();
expect(amountEl!.textContent).toMatch(/XLM$/);
});
});
});
77 changes: 77 additions & 0 deletions src/hooks/__tests__/useDebounce.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useState } from 'react';
import { useDebounce } from '@/hooks/useDebounce';

describe('useDebounce – integration (fake timers)', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('does not update the debounced value before the delay elapses', () => {
const { result } = renderHook(() => {
const [value, setValue] = useState('initial');
const debounced = useDebounce(value, 300);
return { value, setValue, debounced };
});

act(() => {
result.current.setValue('updated');
});

// Immediately after the change the debounced value must still be the old one.
expect(result.current.debounced).toBe('initial');
});

it('updates the debounced value after the delay elapses', () => {
const { result } = renderHook(() => {
const [value, setValue] = useState('initial');
const debounced = useDebounce(value, 300);
return { value, setValue, debounced };
});

act(() => {
result.current.setValue('updated');
});

act(() => {
vi.advanceTimersByTime(300);
});

expect(result.current.debounced).toBe('updated');
});

it('resets the timer on each new value during the debounce window', () => {
const { result } = renderHook(() => {
const [value, setValue] = useState('a');
const debounced = useDebounce(value, 300);
return { value, setValue, debounced };
});

act(() => {
result.current.setValue('b');
});
act(() => {
vi.advanceTimersByTime(150);
});
// Still within the window — another update resets the timer.
act(() => {
result.current.setValue('c');
});
act(() => {
vi.advanceTimersByTime(150);
});
// Only 150 ms have passed since the last update, not yet 300 ms.
expect(result.current.debounced).toBe('a');

act(() => {
vi.advanceTimersByTime(150);
});
// Now the full 300 ms have elapsed since the last value change.
expect(result.current.debounced).toBe('c');
});
});
12 changes: 12 additions & 0 deletions src/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react';

export function useDebounce<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState<T>(value);

useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);

return debounced;
}
5 changes: 4 additions & 1 deletion src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
import { LayoutGroup, motion } from 'framer-motion';
import { useSearchParams } from 'react-router';
import { courseService, type Course } from '@/services/course.service';
Expand Down Expand Up @@ -293,6 +294,7 @@ function LandingPage() {
const [isFilterLoading, setIsFilterLoading] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const [minPriceFilter, setMinPriceFilter] = useState('');
const [maxPriceFilter, setMaxPriceFilter] = useState('');
const searchQueryRef = useRef<string>('');
Expand Down Expand Up @@ -473,6 +475,7 @@ function LandingPage() {
const params = {
...(minPrice !== undefined ? { min_price: minPrice } : {}),
...(maxPrice !== undefined ? { max_price: maxPrice } : {}),
...(debouncedSearchQuery.trim() ? { search: debouncedSearchQuery.trim() } : {}),
};
const data = await courseService.getCourses(
Object.keys(params).length > 0 ? params : undefined
Expand Down Expand Up @@ -516,7 +519,7 @@ function LandingPage() {
};

fetchCreators();
}, [fetchRetryAttempt, fetchRequestId, maxPriceFilter, minPriceFilter]);
}, [fetchRetryAttempt, fetchRequestId, maxPriceFilter, minPriceFilter, debouncedSearchQuery]);

const searchSuggestions = useMemo(() => {
const fromCategories = creators
Expand Down
Loading
Loading