Skip to main content

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
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;

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:

  1. Add keyboard shortcuts - Use lens.useEffect() to listen for keyboard events
  2. Add history - Keep track of previous values and allow undo/redo
  3. Add multiple counters - Create a list of counters with individual persistence
  4. 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>
);
});