Skip to main content

useEffect

The useEffect hook handles side effects and lifecycle management in Refract components. It provides a declarative way to perform operations like data fetching, subscriptions, timers, and DOM manipulation while ensuring proper cleanup and dependency tracking.

Syntax

lens.useEffect(effect, dependencies?)

Parameters

effect

  • Type: () => void | (() => void)
  • Required: Yes
  • Description: Function that contains side effect logic. Can optionally return a cleanup function.

dependencies

  • Type: any[] | undefined
  • Required: No
  • Description: Array of values that the effect depends on. Effect re-runs when dependencies change.

Return Value

Returns void. The effect function can optionally return a cleanup function.

Basic Usage

Mount Effect

const MountExample = createComponent(({ lens }) => {
const data = lens.useRefraction(null);

// Runs once when component mounts
lens.useEffect(() => {
console.log('Component mounted');

fetchInitialData().then(data.set);

// Optional cleanup on unmount
return () => {
console.log('Component unmounting');
};
}, []); // Empty dependency array = mount only

return <div>{data.value || 'Loading...'}</div>;
});

Dependency-Based Effects

const DependencyExample = createComponent(({ lens, userId }) => {
const user = lens.useRefraction(null);
const loading = lens.useRefraction(false);

// Runs when userId changes
lens.useEffect(() => {
if (!userId) return;

loading.set(true);

fetchUser(userId)
.then(user.set)
.finally(() => loading.set(false));
}, [userId]); // Runs when userId prop changes

return (
<div>
{loading.value ? 'Loading...' : user.value?.name}
</div>
);
});

State-Dependent Effects

const StateEffect = createComponent(({ lens }) => {
const searchQuery = lens.useRefraction('');
const results = lens.useRefraction([]);

// Runs when searchQuery changes
lens.useEffect(() => {
if (searchQuery.value.length < 3) {
results.set([]);
return;
}

const searchTimeout = setTimeout(() => {
performSearch(searchQuery.value).then(results.set);
}, 300);

// Cleanup timeout on next effect run
return () => clearTimeout(searchTimeout);
}, [searchQuery.value]);

return (
<div>
<input
value={searchQuery.value}
onChange={(e) => searchQuery.set(e.target.value)}
placeholder="Search..."
/>
<ul>
{results.value.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</div>
);
});

Advanced Patterns

Async Effects

const AsyncEffect = createComponent(({ lens, productId }) => {
const product = lens.useRefraction(null);
const loading = lens.useRefraction(false);
const error = lens.useRefraction(null);

lens.useEffect(() => {
let cancelled = false;

const loadProduct = async () => {
loading.set(true);
error.set(null);

try {
const response = await fetch(`/api/products/${productId}`);

if (!response.ok) {
throw new Error(`Failed to fetch product: ${response.status}`);
}

const productData = await response.json();

// Only update state if effect hasn't been cancelled
if (!cancelled) {
product.set(productData);
}
} catch (err) {
if (!cancelled) {
error.set(err.message);
}
} finally {
if (!cancelled) {
loading.set(false);
}
}
};

loadProduct();

// Cleanup: cancel the async operation
return () => {
cancelled = true;
};
}, [productId]);

if (loading.value) return <div>Loading product...</div>;
if (error.value) return <div>Error: {error.value}</div>;
if (!product.value) return <div>Product not found</div>;

return (
<div>
<h2>{product.value.name}</h2>
<p>{product.value.description}</p>
<p>Price: ${product.value.price}</p>
</div>
);
});

Event Listeners

const EventListenerEffect = createComponent(({ lens }) => {
const windowSize = lens.useRefraction({
width: window.innerWidth,
height: window.innerHeight
});

const mousePosition = lens.useRefraction({ x: 0, y: 0 });
const isTracking = lens.useRefraction(false);

// Window resize listener
lens.useEffect(() => {
const handleResize = () => {
windowSize.set({
width: window.innerWidth,
height: window.innerHeight
});
};

window.addEventListener('resize', handleResize);

return () => {
window.removeEventListener('resize', handleResize);
};
}, []);

// Mouse tracking (conditional)
lens.useEffect(() => {
if (!isTracking.value) return;

const handleMouseMove = (event) => {
mousePosition.set({
x: event.clientX,
y: event.clientY
});
};

document.addEventListener('mousemove', handleMouseMove);

return () => {
document.removeEventListener('mousemove', handleMouseMove);
};
}, [isTracking.value]);

return (
<div>
<p>Window: {windowSize.value.width} x {windowSize.value.height}</p>
<button onClick={() => isTracking.set(!isTracking.value)}>
{isTracking.value ? 'Stop' : 'Start'} Mouse Tracking
</button>
{isTracking.value && (
<p>Mouse: ({mousePosition.value.x}, {mousePosition.value.y})</p>
)}
</div>
);
});

Subscriptions and WebSockets

const WebSocketEffect = createComponent(({ lens, roomId }) => {
const messages = lens.useRefraction([]);
const connectionStatus = lens.useRefraction('disconnected');

lens.useEffect(() => {
if (!roomId) return;

connectionStatus.set('connecting');

const ws = new WebSocket(`ws://localhost:8080/rooms/${roomId}`);

ws.onopen = () => {
connectionStatus.set('connected');
};

ws.onmessage = (event) => {
const message = JSON.parse(event.data);
messages.set(prev => [...prev, message]);
};

ws.onclose = () => {
connectionStatus.set('disconnected');
};

ws.onerror = (error) => {
console.error('WebSocket error:', error);
connectionStatus.set('error');
};

// Cleanup: close WebSocket connection
return () => {
ws.close();
};
}, [roomId]);

const sendMessage = (text) => {
// Implementation would depend on WebSocket state
console.log('Sending message:', text);
};

return (
<div>
<div>Status: {connectionStatus.value}</div>
<div>
{messages.value.map(msg => (
<div key={msg.id}>{msg.user}: {msg.text}</div>
))}
</div>
</div>
);
});

Timers and Intervals

const TimerEffect = createComponent(({ lens }) => {
const time = lens.useRefraction(new Date());
const isRunning = lens.useRefraction(false);
const countdown = lens.useRefraction(60);

// Clock timer
lens.useEffect(() => {
if (!isRunning.value) return;

const interval = setInterval(() => {
time.set(new Date());
}, 1000);

return () => clearInterval(interval);
}, [isRunning.value]);

// Countdown timer
lens.useEffect(() => {
if (countdown.value <= 0) return;

const timer = setTimeout(() => {
countdown.set(prev => prev - 1);
}, 1000);

return () => clearTimeout(timer);
}, [countdown.value]);

return (
<div>
<div>
<h3>Clock</h3>
<p>{time.value.toLocaleTimeString()}</p>
<button onClick={() => isRunning.set(!isRunning.value)}>
{isRunning.value ? 'Stop' : 'Start'} Clock
</button>
</div>

<div>
<h3>Countdown</h3>
<p>{countdown.value} seconds</p>
<button onClick={() => countdown.set(60)}>
Reset Countdown
</button>
</div>
</div>
);
});

Effect Dependencies

Dependency Array Rules

const DependencyRules = createComponent(({ lens, userId, config }) => {
const data = lens.useRefraction(null);

// ✅ Good - Specific dependencies
lens.useEffect(() => {
fetchUserData(userId, config.apiKey).then(data.set);
}, [userId, config.apiKey]);

// ❌ Bad - Missing dependencies
lens.useEffect(() => {
fetchUserData(userId, config.apiKey).then(data.set);
}, [userId]); // Missing config.apiKey

// ❌ Bad - Unnecessary dependencies
lens.useEffect(() => {
fetchUserData(userId).then(data.set);
}, [userId, config]); // config not used in effect

return <div>{data.value?.name}</div>;
});

Memoizing Dependencies

const MemoizedDependencies = createComponent(({ lens, items }) => {
const processedItems = lens.useRefraction([]);

// Memoize expensive computation for dependency
const itemIds = lens.useDerived(() =>
items.map(item => item.id).join(','),
[items]
);

lens.useEffect(() => {
// Only runs when item IDs change, not when other item properties change
const processed = items.map(processItem);
processedItems.set(processed);
}, [itemIds.value]);

return (
<ul>
{processedItems.value.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});

Error Handling

Try-Catch in Effects

const ErrorHandlingEffect = createComponent(({ lens }) => {
const data = lens.useRefraction(null);
const error = lens.useRefraction(null);
const loading = lens.useRefraction(false);

lens.useEffect(() => {
const loadData = async () => {
try {
loading.set(true);
error.set(null);

const result = await riskyAsyncOperation();
data.set(result);
} catch (err) {
console.error('Effect error:', err);
error.set(err.message);
} finally {
loading.set(false);
}
};

loadData();
}, []);

if (loading.value) return <div>Loading...</div>;
if (error.value) return <div>Error: {error.value}</div>;

return <div>{JSON.stringify(data.value)}</div>;
});

Effect Error Boundaries

const withEffectErrorHandling = (Component) => {
return createComponent((props) => {
const { lens } = props;
const hasEffectError = lens.useRefraction(false);
const effectError = lens.useRefraction(null);

// Wrap effects with error handling
const safeUseEffect = (effect, deps) => {
lens.useEffect(() => {
try {
const cleanup = effect();
return cleanup;
} catch (error) {
hasEffectError.set(true);
effectError.set(error);
}
}, deps);
};

if (hasEffectError.value) {
return (
<div className="effect-error">
<h3>Effect Error</h3>
<p>{effectError.value?.message}</p>
<button onClick={() => hasEffectError.set(false)}>
Retry
</button>
</div>
);
}

return <Component {...props} useEffect={safeUseEffect} />;
});
};

Performance Optimization

Conditional Effects

const ConditionalEffect = createComponent(({ lens, isEnabled, shouldFetch }) => {
const data = lens.useRefraction(null);

lens.useEffect(() => {
// Only run effect when conditions are met
if (!isEnabled || !shouldFetch) return;

fetchData().then(data.set);
}, [isEnabled, shouldFetch]);

return <div>{data.value || 'No data'}</div>;
});

Debounced Effects

const DebouncedEffect = createComponent(({ lens }) => {
const searchTerm = lens.useRefraction('');
const results = lens.useRefraction([]);

lens.useEffect(() => {
if (!searchTerm.value) {
results.set([]);
return;
}

// Debounce the search
const timeoutId = setTimeout(() => {
performSearch(searchTerm.value).then(results.set);
}, 300);

return () => clearTimeout(timeoutId);
}, [searchTerm.value]);

return (
<div>
<input
value={searchTerm.value}
onChange={(e) => searchTerm.set(e.target.value)}
placeholder="Search..."
/>
<ul>
{results.value.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</div>
);
});

Testing Effects

Mocking Effects

import { render, waitFor } from '@refract/testing-utils';

// Mock fetch for testing
global.fetch = jest.fn();

describe('AsyncEffect Component', () => {
beforeEach(() => {
fetch.mockClear();
});

test('fetches data on mount', async () => {
const mockData = { id: 1, name: 'Test Product' };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockData
});

const { getByText } = render(<AsyncEffect productId={1} />);

// Initially shows loading
expect(getByText('Loading product...')).toBeInTheDocument();

// Wait for data to load
await waitFor(() => {
expect(getByText('Test Product')).toBeInTheDocument();
});

expect(fetch).toHaveBeenCalledWith('/api/products/1');
});

test('handles fetch errors', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));

const { getByText } = render(<AsyncEffect productId={1} />);

await waitFor(() => {
expect(getByText('Error: Network error')).toBeInTheDocument();
});
});
});

Testing Cleanup

test('cleans up event listeners on unmount', () => {
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');

const { unmount } = render(<EventListenerEffect />);

expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));

unmount();

expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function));

addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});

Best Practices

1. Always Handle Cleanup

// ✅ Good
lens.useEffect(() => {
const subscription = api.subscribe(handleData);
return () => subscription.unsubscribe();
}, []);

// ❌ Bad - No cleanup
lens.useEffect(() => {
api.subscribe(handleData);
}, []);

2. Use Specific Dependencies

// ✅ Good
lens.useEffect(() => {
fetchUser(userId);
}, [userId]);

// ❌ Bad - Missing dependencies
lens.useEffect(() => {
fetchUser(userId);
}, []);

3. Handle Async Operations Safely

// ✅ Good
lens.useEffect(() => {
let cancelled = false;

fetchData().then(data => {
if (!cancelled) {
setData(data);
}
});

return () => {
cancelled = true;
};
}, []);

// ❌ Bad - Race conditions possible
lens.useEffect(() => {
fetchData().then(setData);
}, []);

4. Separate Concerns

// ✅ Good - Separate effects for different concerns
lens.useEffect(() => {
fetchUserData(userId).then(setUser);
}, [userId]);

lens.useEffect(() => {
trackPageView(pageName);
}, [pageName]);

// ❌ Bad - Mixed concerns
lens.useEffect(() => {
fetchUserData(userId).then(setUser);
trackPageView(pageName);
}, [userId, pageName]);