Skip to main content

Side Effects

Side effects are operations that reach outside your component's pure rendering logic—things like API calls, timers, DOM manipulation, or subscribing to external data sources. Refract provides several tools to handle side effects safely and predictably.

What Are Side Effects?​

In the context of UI frameworks, a side effect is anything that isn't directly related to rendering your component's output. Pure components take props and state, then return JSX. Side effects are everything else:

  • Fetching data from an API
  • Setting up event listeners
  • Starting or clearing timers
  • Manually updating the DOM
  • Subscribing to external data sources
  • Sending analytics events
// ✅ Pure rendering - no side effects
const DisplayUser = createComponent(({ lens, user }) => {
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
});

// ❌ Impure - contains side effects
const BadUserProfile = createComponent(({ lens, userId }) => {
// Side effect happening during render - this is bad!
fetch(`/api/users/${userId}`)
.then(response => response.json())
.then(user => {
// This won't work properly and could cause infinite loops
document.getElementById('user-name').textContent = user.name;
});

return <div id="user-name">Loading...</div>;
});

// ✅ Proper side effect handling
const GoodUserProfile = createComponent(({ lens, userId }) => {
const user = lens.useRefraction(null);
const loading = lens.useRefraction(true);

// Side effect properly handled in useEffect
lens.useEffect(async () => {
loading.set(true);
try {
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
user.set(userData);
} finally {
loading.set(false);
}
}, [userId]);

if (loading.value) return <div>Loading...</div>;
if (!user.value) return <div>User not found</div>;

return (
<div>
<h2>{user.value.name}</h2>
<p>{user.value.email}</p>
</div>
);
});

The useEffect Method​

The most common way to handle side effects is through lens.useEffect(). This method runs after your component renders and provides a clean way to perform side effects.

Basic Effect Usage​

const TimerComponent = createComponent(({ lens }) => {
const seconds = lens.useRefraction(0);

lens.useEffect(() => {
const intervalId = setInterval(() => {
seconds.set(current => current + 1);
}, 1000);

// Cleanup function - runs when component unmounts or effect re-runs
return () => {
clearInterval(intervalId);
};
}, []); // Empty dependency array means this runs once on mount

return <div>Timer: {seconds.value} seconds</div>;
});

Effects with Dependencies​

Effects can watch for changes in specific values and re-run when those values change:

const SearchResults = createComponent(({ lens, searchQuery }) => {
const results = lens.useRefraction([]);
const loading = lens.useRefraction(false);

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

loading.set(true);
try {
const response = await fetch(`/api/search?q=${searchQuery}`);
const data = await response.json();
results.set(data);
} catch (error) {
console.error('Search failed:', error);
results.set([]);
} finally {
loading.set(false);
}
}, [searchQuery]); // Re-run when searchQuery changes

if (loading.value) return <div>Searching...</div>;

return (
<ul>
{results.value.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
);
});

The useFlash Method​

Sometimes you need to run a side effect exactly once after the component renders—like triggering an animation or focusing an input. This is what useFlash is for:

const AnimatedModal = createComponent(({ lens, isVisible }) => {
const modalRef = lens.useRefraction(null);

lens.useFlash(() => {
if (isVisible && modalRef.value) {
// Trigger entrance animation
modalRef.value.classList.add('animate-in');

// Focus the first input
const firstInput = modalRef.value.querySelector('input');
if (firstInput) firstInput.focus();
}
});

if (!isVisible) return null;

return (
<div
ref={(el) => modalRef.set(el)}
className="modal"
>
<input placeholder="Enter your name" />
<button>Submit</button>
</div>
);
});

Flash vs Effect​

Use useFlash when you need something to happen once immediately after render, and useEffect when you need to respond to changes or set up ongoing operations:

const NotificationToast = createComponent(({ lens, message, onDismiss }) => {
const toastRef = lens.useRefraction(null);

// Flash: Trigger entrance animation once
lens.useFlash(() => {
if (toastRef.value) {
toastRef.value.classList.add('slide-in');
}
});

// Effect: Set up auto-dismiss timer
lens.useEffect(() => {
const timeoutId = setTimeout(onDismiss, 5000);
return () => clearTimeout(timeoutId);
}, []);

return (
<div
ref={(el) => toastRef.set(el)}
className="toast"
>
{message}
<button onClick={onDismiss}>×</button>
</div>
);
});

Event Listeners and Subscriptions​

Managing event listeners is a common side effect pattern:

Window Events​

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

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

window.addEventListener('resize', handleResize);

// Cleanup: Remove listener when component unmounts
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);

return (
<div>
Window size: {windowSize.value.width} × {windowSize.value.height}
</div>
);
});

Keyboard Events​

const KeyboardShortcuts = createComponent(({ lens, onSave, onCancel }) => {
lens.useEffect(() => {
const handleKeyDown = (event) => {
if (event.metaKey || event.ctrlKey) {
switch (event.key) {
case 's':
event.preventDefault();
onSave();
break;
case 'Escape':
event.preventDefault();
onCancel();
break;
}
}
};

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onSave, onCancel]);

return (
<div>
<p>Press Ctrl+S to save, Escape to cancel</p>
{/* Your component content */}
</div>
);
});

WebSocket Subscriptions​

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

lens.useEffect(() => {
const ws = new WebSocket(`wss://api.example.com/chat/${roomId}`);

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

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

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

ws.onerror = () => {
connectionStatus.set('error');
};

// Cleanup: Close WebSocket when component unmounts or roomId changes
return () => {
ws.close();
};
}, [roomId]);

return (
<div>
<div className={`status ${connectionStatus.value}`}>
Status: {connectionStatus.value}
</div>
<div className="messages">
{messages.value.map(msg => (
<div key={msg.id} className="message">
<strong>{msg.user}:</strong> {msg.text}
</div>
))}
</div>
</div>
);
});

DOM Manipulation​

Sometimes you need to directly manipulate DOM elements:

Focus Management​

const SearchInput = createComponent(({ lens, autoFocus }) => {
const inputRef = lens.useRefraction(null);

lens.useEffect(() => {
if (autoFocus && inputRef.value) {
inputRef.value.focus();
}
}, [autoFocus]);

return (
<input
ref={(el) => inputRef.set(el)}
placeholder="Search..."
/>
);
});

Scroll Management​

const ScrollToTop = createComponent(({ lens, trigger }) => {
lens.useEffect(() => {
if (trigger) {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
}, [trigger]);

return null; // This component doesn't render anything visible
});

Canvas Drawing​

const CanvasDrawing = createComponent(({ lens, width = 400, height = 300 }) => {
const canvasRef = lens.useRefraction(null);
const drawing = lens.useRefraction(false);

lens.useEffect(() => {
const canvas = canvasRef.value;
if (!canvas) return;

const ctx = canvas.getContext('2d');
let lastX = 0;
let lastY = 0;

const startDrawing = (e) => {
drawing.set(true);
const rect = canvas.getBoundingClientRect();
lastX = e.clientX - rect.left;
lastY = e.clientY - rect.top;
};

const draw = (e) => {
if (!drawing.value) return;

const rect = canvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;

ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(currentX, currentY);
ctx.stroke();

lastX = currentX;
lastY = currentY;
};

const stopDrawing = () => {
drawing.set(false);
};

canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', stopDrawing);
canvas.addEventListener('mouseout', stopDrawing);

return () => {
canvas.removeEventListener('mousedown', startDrawing);
canvas.removeEventListener('mousemove', draw);
canvas.removeEventListener('mouseup', stopDrawing);
canvas.removeEventListener('mouseout', stopDrawing);
};
}, []);

return (
<canvas
ref={(el) => canvasRef.set(el)}
width={width}
height={height}
style={{ border: '1px solid #ccc' }}
/>
);
});

API Integration Patterns​

Data Fetching with Loading States​

const UserList = createComponent(({ lens, filters }) => {
const users = lens.useRefraction([]);
const loading = lens.useRefraction(false);
const error = lens.useRefraction(null);

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

try {
const params = new URLSearchParams(filters);
const response = await fetch(`/api/users?${params}`);

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const userData = await response.json();
users.set(userData);
} catch (err) {
error.set(err.message);
users.set([]);
} finally {
loading.set(false);
}
};

lens.useEffect(() => {
fetchUsers();
}, [JSON.stringify(filters)]); // Re-fetch when filters change

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

return (
<div>
<button onClick={fetchUsers}>Refresh</button>
<ul>
{users.value.map(user => (
<li key={user.id}>{user.name} - {user.email}</li>
))}
</ul>
</div>
);
});

Real-time Updates​

const LiveDataFeed = createComponent(({ lens, feedId }) => {
const data = lens.useRefraction([]);
const lastUpdate = lens.useRefraction(null);

lens.useEffect(() => {
let isActive = true;

const poll = async () => {
try {
const response = await fetch(`/api/feed/${feedId}/updates`);
const updates = await response.json();

if (isActive) {
data.set(updates);
lastUpdate.set(new Date().toLocaleTimeString());
}
} catch (err) {
console.error('Failed to fetch updates:', err);
}

// Continue polling if component is still active
if (isActive) {
setTimeout(poll, 5000);
}
};

poll();

return () => {
isActive = false; // Stop polling when component unmounts
};
}, [feedId]);

return (
<div>
<div>Last update: {lastUpdate.value || 'Never'}</div>
<ul>
{data.value.map(item => (
<li key={item.id}>{item.content}</li>
))}
</ul>
</div>
);
});

Animation and Timing Effects​

CSS Transition Management​

const FadeTransition = createComponent(({ lens, visible, children }) => {
const elementRef = lens.useRefraction(null);
const isTransitioning = lens.useRefraction(false);

lens.useEffect(() => {
const element = elementRef.value;
if (!element) return;

isTransitioning.set(true);

if (visible) {
element.style.display = 'block';
element.style.opacity = '0';

// Force a reflow
element.offsetHeight;

element.style.transition = 'opacity 300ms ease-in-out';
element.style.opacity = '1';
} else {
element.style.transition = 'opacity 300ms ease-in-out';
element.style.opacity = '0';
}

const handleTransitionEnd = () => {
if (!visible) {
element.style.display = 'none';
}
isTransitioning.set(false);
element.removeEventListener('transitionend', handleTransitionEnd);
};

element.addEventListener('transitionend', handleTransitionEnd);

return () => {
element.removeEventListener('transitionend', handleTransitionEnd);
};
}, [visible]);

return (
<div
ref={(el) => elementRef.set(el)}
style={{ display: visible ? 'block' : 'none' }}
>
{children}
</div>
);
});

Countdown Timer​

const CountdownTimer = createComponent(({ lens, targetDate, onComplete }) => {
const timeLeft = lens.useRefraction({
days: 0,
hours: 0,
minutes: 0,
seconds: 0
});

lens.useEffect(() => {
const calculateTimeLeft = () => {
const difference = new Date(targetDate) - new Date();

if (difference > 0) {
return {
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
minutes: Math.floor((difference / 1000 / 60) % 60),
seconds: Math.floor((difference / 1000) % 60)
};
} else {
return { days: 0, hours: 0, minutes: 0, seconds: 0 };
}
};

const updateTimer = () => {
const time = calculateTimeLeft();
timeLeft.set(time);

// Check if countdown is complete
if (time.days === 0 && time.hours === 0 && time.minutes === 0 && time.seconds === 0) {
onComplete();
return; // Stop the interval
}
};

updateTimer(); // Initial calculation
const intervalId = setInterval(updateTimer, 1000);

return () => clearInterval(intervalId);
}, [targetDate, onComplete]);

return (
<div className="countdown">
<div className="time-unit">
<span className="value">{timeLeft.value.days}</span>
<span className="label">Days</span>
</div>
<div className="time-unit">
<span className="value">{timeLeft.value.hours}</span>
<span className="label">Hours</span>
</div>
<div className="time-unit">
<span className="value">{timeLeft.value.minutes}</span>
<span className="label">Minutes</span>
</div>
<div className="time-unit">
<span className="value">{timeLeft.value.seconds}</span>
<span className="label">Seconds</span>
</div>
</div>
);
});

Side Effect Best Practices​

Always Clean Up​

Every side effect that creates a resource should clean it up:

// ✅ Good - always cleanup
lens.useEffect(() => {
const eventSource = new EventSource('/api/events');
eventSource.onmessage = handleMessage;

return () => {
eventSource.close(); // Always clean up
};
}, []);

// ❌ Bad - no cleanup (memory leak)
lens.useEffect(() => {
const eventSource = new EventSource('/api/events');
eventSource.onmessage = handleMessage;
// No cleanup - this will leak resources
}, []);

Handle Dependencies Correctly​

Include all variables that your effect depends on:

// ✅ Good - all dependencies included
const SearchComponent = createComponent(({ lens, query, sortBy }) => {
const results = lens.useRefraction([]);

lens.useEffect(async () => {
const response = await fetch(`/api/search?q=${query}&sort=${sortBy}`);
const data = await response.json();
results.set(data);
}, [query, sortBy]); // Both dependencies listed
});

// ❌ Bad - missing dependencies
lens.useEffect(async () => {
const response = await fetch(`/api/search?q=${query}&sort=${sortBy}`);
const data = await response.json();
results.set(data);
}, [query]); // Missing 'sortBy' - effect won't re-run when sortBy changes

Avoid Infinite Loops​

Be careful with dependencies to prevent effects from running continuously:

// ✅ Good - stable dependencies
const DataComponent = createComponent(({ lens, config }) => {
const data = lens.useRefraction(null);

lens.useEffect(async () => {
const response = await fetch(config.url);
const result = await response.json();
data.set(result);
}, [config.url]); // Only depend on the specific property
});

// ❌ Bad - unstable dependency
lens.useEffect(async () => {
const response = await fetch(config.url);
const result = await response.json();
data.set(result);
}, [config]); // 'config' might be a new object every render

Handle Race Conditions​

When dealing with async operations, handle the case where a new request starts before the previous one completes:

const AsyncSearch = createComponent(({ lens, searchTerm }) => {
const results = lens.useRefraction([]);
const loading = lens.useRefraction(false);

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

let cancelled = false;

const performSearch = async () => {
loading.set(true);

try {
const response = await fetch(`/api/search?q=${searchTerm}`);
const data = await response.json();

// Only update state if this effect hasn't been cancelled
if (!cancelled) {
results.set(data);
}
} catch (error) {
if (!cancelled) {
console.error('Search failed:', error);
results.set([]);
}
} finally {
if (!cancelled) {
loading.set(false);
}
}
};

performSearch();

// Cancel this effect if searchTerm changes before it completes
return () => {
cancelled = true;
};
}, [searchTerm]);

return (
<div>
{loading.value && <div>Searching...</div>}
<ul>
{results.value.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
</div>
);
});

Debugging Side Effects​

Development Mode Logging​

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

lens.useEffect(async () => {
if (process.env.NODE_ENV === 'development') {
console.log('Fetching user:', userId);
}

try {
const response = await fetch(`/api/users/${userId}`);
const userData = await response.json();
user.set(userData);

if (process.env.NODE_ENV === 'development') {
console.log('User loaded:', userData);
}
} catch (error) {
console.error('Failed to load user:', error);
}
}, [userId]);

return user.value ? <UserDisplay user={user.value} /> : <div>Loading...</div>;
});

Effect Dependency Warnings​

Refract can warn you about common effect mistakes in development mode:

// This will warn in development if dependencies are missing
lens.useEffect(() => {
doSomethingWith(someVariable);
}, []); // Warning: 'someVariable' is used but not in dependencies

// This will warn about unnecessary dependencies
lens.useEffect(() => {
console.log('Effect ran');
}, [unnecessaryVariable]); // Warning: 'unnecessaryVariable' is not used in this effect

Common Side Effect Patterns​

Data Loading with Retry Logic​

function useApiWithRetry(url, maxRetries = 3) {
return useOptic((lens) => {
const data = lens.useRefraction(null);
const loading = lens.useRefraction(false);
const error = lens.useRefraction(null);
const retryCount = lens.useRefraction(0);

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

for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);

const result = await response.json();
data.set(result);
retryCount.set(0);
return;
} catch (err) {
if (attempt === maxRetries) {
error.set(`Failed after ${maxRetries + 1} attempts: ${err.message}`);
} else {
retryCount.set(attempt + 1);
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
}

loading.set(false);
};

lens.useEffect(() => {
if (url) fetchData();
}, [url]);

return {
data: data.value,
loading: loading.value,
error: error.value,
retryCount: retryCount.value,
refetch: fetchData
};
});
}

Intersection Observer​

const LazyImage = createComponent(({ lens, src, alt, placeholder }) => {
const imageRef = lens.useRefraction(null);
const isVisible = lens.useRefraction(false);
const hasLoaded = lens.useRefraction(false);

lens.useEffect(() => {
const element = imageRef.value;
if (!element || !('IntersectionObserver' in window)) {
isVisible.set(true);
return;
}

const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
isVisible.set(true);
observer.disconnect();
}
},
{ threshold: 0.1 }
);

observer.observe(element);

return () => observer.disconnect();
}, []);

return (
<div ref={(el) => imageRef.set(el)}>
{isVisible.value ? (
<img
src={src}
alt={alt}
onLoad={() => hasLoaded.set(true)}
style={{ opacity: hasLoaded.value ? 1 : 0, transition: 'opacity 0.3s' }}
/>
) : (
<div className="placeholder">{placeholder || 'Loading...'}</div>
)}
</div>
);
});

Side effects are a powerful part of Refract that let you integrate with the broader web platform. By following the patterns and best practices outlined here, you can handle complex interactions while keeping your code predictable and maintainable.

The key is to always think about cleanup, handle your dependencies correctly, and keep your effects focused on single responsibilities. This makes your components more reliable and easier to debug when things go wrong.