Building a Counter App
In this tutorial, we'll build a counter application that demonstrates Refract's core concepts: reactive state, side effects, and persistence. By the end, you'll have a counter that remembers its value between page reloads.
What We'll Build
Reactive Counter
A counter that updates the UI automatically when the value changes.
Persistent State
Counter value is saved to localStorage and restored on page reload.
Interactive UI
Multiple buttons for increment, decrement, and reset operations.
Step 1: Basic Counter
Let's start with a simple counter component:
Basic Counter Component
Creating a counter with reactive state:
import { createComponent } from "refract";
const Counter = createComponent(({ lens }) => {
// Create reactive state for the counter value
const count = lens.useRefraction(0);
return (
<div className="counter">
<h2>Counter App</h2>
<div className="count-display">
{count.value}
</div>
<div className="buttons">
<button onClick={() => count.set(count.value - 1)}>
-
</button>
<button onClick={() => count.set(count.value + 1)}>
+
</button>
<button onClick={() => count.set(0)}>
Reset
</button>
</div>
</div>
);
});
export default Counter;
Step 2: Add Styling
Let's make our counter look better with some CSS:
Counter Styles
Adding styles to make the counter visually appealing:
.counter {
max-width: 300px;
margin: 2rem auto;
padding: 2rem;
border: 2px solid #e1e5e9;
border-radius: 12px;
text-align: center;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.counter h2 {
margin: 0 0 1.5rem 0;
color: #2c3e50;
font-size: 1.5rem;
}
.count-display {
font-size: 3rem;
font-weight: bold;
color: #3498db;
margin: 1.5rem 0;
padding: 1rem;
background: white;
border-radius: 8px;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.buttons {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.buttons button {
padding: 0.75rem 1.5rem;
font-size: 1.2rem;
font-weight: bold;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
min-width: 60px;
}
.buttons button:first-child,
.buttons button:nth-child(2) {
background: #3498db;
color: white;
}
.buttons button:last-child {
background: #e74c3c;
color: white;
}
.buttons button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.buttons button:active {
transform: translateY(0);
}
Step 3: Add Persistence
Now let's make the counter remember its value using localStorage:
Persistent Counter
Adding localStorage persistence to remember the counter value:
import { createComponent } from "refract";
const PersistentCounter = createComponent(({ lens }) => {
const count = lens.useRefraction(0);
// Load saved value from localStorage on component mount
lens.useEffect(() => {
const savedCount = localStorage.getItem('counter-value');
if (savedCount !== null) {
try {
const parsedCount = parseInt(savedCount, 10);
if (!isNaN(parsedCount)) {
count.set(parsedCount);
}
} catch (error) {
console.warn('Failed to parse saved counter value:', error);
}
}
}, []); // Empty dependency array = run once on mount
// Save to localStorage whenever count changes
lens.useEffect(() => {
localStorage.setItem('counter-value', count.value.toString());
}, [count.value]); // Run whenever count changes
return (
<div className="counter">
<h2>Persistent Counter</h2>
<div className="count-display">
{count.value}
</div>
<div className="buttons">
<button onClick={() => count.set(count.value - 1)}>
-
</button>
<button onClick={() => count.set(count.value + 1)}>
+
</button>
<button onClick={() => count.set(0)}>
Reset
</button>
</div>
<p className="persistence-note">
💾 Your count is automatically saved!
</p>
</div>
);
});
export default PersistentCounter;
Step 4: Add Step Size Control
Let's make the counter more flexible by allowing custom step sizes:
Counter with Step Size
Adding a step size control for more flexible counting:
const FlexibleCounter = createComponent(({ lens }) => {
const count = lens.useRefraction(0);
const stepSize = lens.useRefraction(1);
// Persistence effects (same as before)
lens.useEffect(() => {
const savedCount = localStorage.getItem('counter-value');
const savedStep = localStorage.getItem('counter-step');
if (savedCount !== null) {
const parsedCount = parseInt(savedCount, 10);
if (!isNaN(parsedCount)) {
count.set(parsedCount);
}
}
if (savedStep !== null) {
const parsedStep = parseInt(savedStep, 10);
if (!isNaN(parsedStep) && parsedStep > 0) {
stepSize.set(parsedStep);
}
}
}, []);
lens.useEffect(() => {
localStorage.setItem('counter-value', count.value.toString());
}, [count.value]);
lens.useEffect(() => {
localStorage.setItem('counter-step', stepSize.value.toString());
}, [stepSize.value]);
const increment = () => count.set(count.value + stepSize.value);
const decrement = () => count.set(count.value - stepSize.value);
const reset = () => count.set(0);
return (
<div className="counter">
<h2>Flexible Counter</h2>
<div className="step-control">
<label>
Step Size:
<input
type="number"
min="1"
value={stepSize.value}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
stepSize.set(value);
}
}}
/>
</label>
</div>
<div className="count-display">
{count.value}
</div>
<div className="buttons">
<button onClick={decrement}>
-{stepSize.value}
</button>
<button onClick={increment}>
+{stepSize.value}
</button>
<button onClick={reset}>
Reset
</button>
</div>
<p className="persistence-note">
💾 Count and step size are saved automatically!
</p>
</div>
);
});
Add the animation CSS:
Animation Styles
CSS for the counter animation effects:
.count-display.animating {
animation: pulse 0.3s ease-in-out;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
background: #e8f4fd;
}
100% {
transform: scale(1);
}
}
.step-control {
margin-bottom: 1.5rem;
}
.step-control label {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
color: #2c3e50;
}
.step-control input {
width: 60px;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
text-align: center;
}
.persistence-note {
margin-top: 1rem;
font-size: 0.9rem;
color: #7f8c8d;
font-style: italic;
}
Complete Application
Here's how to put it all together in your main app:
- App.js
- main.js
import { createComponent } from "refract";
import AnimatedCounter from "./components/AnimatedCounter.js";
import "./styles/counter.css";
const App = createComponent(({ lens }) => {
return (
<div className="app">
<header>
<h1>My Refract App</h1>
</header>
<main>
<AnimatedCounter />
</main>
</div>
);
});
export default App;
import { createApp } from "refract";
import App from "./App.js";
const app = createApp(App);
app.mount("#root");
What You've Learned
Congratulations! You've built a complete counter application that demonstrates several key Refract concepts:
Reactive State
Used lens.useRefraction() to create reactive state that automatically updates the UI.
Side Effects
Used lens.useEffect() to handle localStorage persistence and animations.
Event Handling
Handled user interactions with onClick handlers that update reactive state.
Next Steps
Now that you understand the basics, try these enhancements:
- Add keyboard shortcuts - Use
lens.useEffect()to listen for keyboard events - Add history - Keep track of previous values and allow undo/redo
- Add multiple counters - Create a list of counters with individual persistence
- Add themes - Implement a theme switcher (see our Global Theme Tutorial)
Key Takeaways
- Reactive state with
lens.useRefraction()automatically updates the UI - Side effects with
lens.useEffect()handle persistence and animations - Dependency arrays control when effects run
- Cleanup functions prevent memory leaks and manage resources
- Immutable updates ensure proper reactivity
Ready for more? Check out the Global Theme Tutorial to learn about sharing state across components!
Animated Counter
Adding animations for better user experience:
const AnimatedCounter = createComponent(({ lens }) => {
const count = lens.useRefraction(0);
const stepSize = lens.useRefraction(1);
const isAnimating = lens.useRefraction(false);
// Persistence effects (same as before)
lens.useEffect(() => {
const savedCount = localStorage.getItem('counter-value');
const savedStep = localStorage.getItem('counter-step');
if (savedCount !== null) {
const parsedCount = parseInt(savedCount, 10);
if (!isNaN(parsedCount)) {
count.set(parsedCount);
}
}
if (savedStep !== null) {
const parsedStep = parseInt(savedStep, 10);
if (!isNaN(parsedStep) && parsedStep > 0) {
stepSize.set(parsedStep);
}
}
}, []);
lens.useEffect(() => {
localStorage.setItem('counter-value', count.value.toString());
}, [count.value]);
lens.useEffect(() => {
localStorage.setItem('counter-step', stepSize.value.toString());
}, [stepSize.value]);
// Animation effect
lens.useEffect(() => {
if (isAnimating.value) {
const timer = setTimeout(() => {
isAnimating.set(false);
}, 300);
return () => clearTimeout(timer);
}
}, [isAnimating.value]);
const animatedChange = (newValue) => {
count.set(newValue);
isAnimating.set(true);
};
const increment = () => animatedChange(count.value + stepSize.value);
const decrement = () => animatedChange(count.value - stepSize.value);
const reset = () => animatedChange(0);
return (
<div className="counter">
<h2>Animated Counter</h2>
<div className="step-control">
<label>
Step Size:
<input
type="number"
min="1"
value={stepSize.value}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value) && value > 0) {
stepSize.set(value);
}
}}
/>
</label>
</div>
<div className={`count-display ${isAnimating.value ? 'animating' : ''}`}>
{count.value}
</div>
<div className="buttons">
<button onClick={decrement}>
-{stepSize.value}
</button>
<button onClick={increment}>
+{stepSize.value}
</button>
<button onClick={reset}>
Reset
</button>
</div>
<p className="persistence-note">
💾 Count and step size are saved automatically!
</p>
</div>
);
});