Events
Sampling states and events with atoms and actions: The reactive event pattern that will change how you think about data flow!
I am the author of the Reatom state manager, and today I want to introduce you to one of the most powerful yet underappreciated patterns in reactive programming: sampling states and events using atoms and actions.
While most state managers force you to choose between imperative events or reactive state, Reatom bridges this gap with an elegant unification of both paradigms. This approach provides the clarity of event-driven programming with the consistency of reactive state management.
The Problem with Traditional Approaches
Section titled “The Problem with Traditional Approaches”Traditional state management typically falls into one of two categories:
- Event-driven approaches where events trigger reactive streams (RxJS)
- State-driven approaches where derived values automatically update (MobX, signals)
Each has its strengths, but also critical weaknesses. Event-driven systems need a lot of additional methods to handle complex state properly. Reactive systems with “excel” design fails with event tracking and proper async logic handling.
What if we could have the best of both worlds?
Actions as Reactive Events: A Core Insight
Section titled “Actions as Reactive Events: A Core Insight”The key insight of Reatom is treating actions as first-class reactive events that can be both triggered and observed. Let’s see a simple example:
import { atom, action } from '@reatom/core'
// Create an atom - a state containerconst counter = atom(0, 'counter')
// Create an action - a callable function that also works as an event emitterconst increment = action((amount = 1) => { counter.set(counter() + amount) return counter()}, 'increment')
// Subscribe to state changescounter.subscribe((count) => { console.log(`Counter is now: ${count}`)})
// Call the action like a normal functionincrement(10) // Counter is now: 10So far, this looks like a typical action pattern. But here’s where Reatom’s unique perspective shines: actions themselves are observable reactive entities. This means you can subscribe to action calls just like you subscribe to atom changes:
// Subscribe to action callsincrement.subscribe((calls) => { console.log('Counter calls:', ...calls)})
// Call the action like a normal functionincrement()increment(5)// To the next tick:// Counter calls: { params: [], payload: 11 }, { params: [5], payload: 16 }This dual nature of actions as both callable functions and observable events creates a foundation for powerful patterns that are difficult to implement in other libraries.
The take Operator: Awaiting Events Procedurally
Section titled “The take Operator: Awaiting Events Procedurally”One of the most powerful capabilities this enables is the take operator. It allows you to wait for specific events or state changes in an asynchronous context, similar to redux-saga’s API but with native async/await syntax:
import { take, wrap } from '@reatom/core'
const saveUser = action(async () => { // Wait for specific user input const userData = await wrap(take(submitUserForm))
// Submit to server const response = await wrap(api.saveUser(userData))
if (response.success) { // Wait for form confirmation await wrap(take(confirmSave)) successAtom.set(true) }}, 'saveUser')This allows you to describe complex workflows procedurally while still maintaining a reactive connection to your application’s events.
The Problem with Traditional Event Sampling
Section titled “The Problem with Traditional Event Sampling”In traditional state management, coordinating between events and state often requires complex state machines or tangled subscriptions. Consider this common scenario: you need to listen for an event but access the latest state at the moment the event occurs.
Here’s how it might look with traditional approaches:
// Redux/traditional approachconst mapStateToProps = (state) => ({ filters: state.filters,})
const mapDispatchToProps = (dispatch) => ({ onSearch: () => { // Need to somehow get the current filters here... const currentFilters = ??? dispatch(searchWithFilters(currentFilters)) }})Getting access to the current state when an event happens requires convoluted patterns like:
- Storing duplicate state in component
- Creating closure-based references
- Using refs in React
- Complex selector patterns
Reatom’s Solution: Direct State Access
Section titled “Reatom’s Solution: Direct State Access”Reatom provides a remarkably clean solution to this problem with direct state access. Within any action or reactive function, you can simply call any atom as a function to get its current value:
import { atom, action, computed, wrap } from '@reatom/core'
const filtersAtom = atom({ text: '', category: 'all' }, 'filters')const resultsAtom = atom([], 'results')
const search = action(async () => { // Access any atom's current state directly const filters = filtersAtom()
// Use that state in an API call const results = await wrap(searchApi(filters))
// Update results resultsAtom.set(results)}, 'search')
// Attach to a button in UIsearchButton.addEventListener('click', wrap(search))This eliminates an entire category of state management problems by making any state accessible at the point where it’s needed.
Combining Actions and Atoms with take
Section titled “Combining Actions and Atoms with take”Now let’s see how these concepts come together to create truly elegant solutions to complex problems.
Imagine we’re building a form with real-time validation and a submit button that’s only enabled when validation passes. Here’s how we might approach this with take and the action-as-event pattern:
import { atom, action, computed, take, wrap } from '@reatom/core'
// Form state atomsconst usernameAtom = atom('', 'username')const passwordAtom = atom('', 'password')
const isValidAtom = computed(() => { const username = usernameAtom() const password = passwordAtom() return username.length >= 3 && password.length >= 6}, 'isValid')
// Form actionsconst setUsername = action((value) => { usernameAtom.set(value)}, 'setUsername')
const setPassword = action((value) => { passwordAtom.set(value)}, 'setPassword')
const submit = action(async () => { // Only proceed if form is valid if (!isValidAtom()) { return }
const username = usernameAtom() const password = passwordAtom()
// Submit form await wrap(api.register({ username, password }))}, 'submit')
// Now here's where the magic happens - a procedure that coordinates the whole form flowconst formFlowController = action(async () => { // Wait until the form becomes valid if (!isValidAtom()) { await wrap(take(isValidAtom, (isValid, skip) => (isValid ? isValid : skip))) console.log('Form is now valid') }
// Wait for the submit button to be clicked await wrap(take(submit)) console.log('Form submitted')
// Show success message and wait for user acknowledgment const showSuccessMessageAtom = atom(false, 'showSuccessMessage') showSuccessMessageAtom.set(true)
// Wait for a specific button click to close the success message const closeButton = document.getElementById('closeSuccess') await wrap(onEvent(closeButton, 'click'))
console.log('Flow complete')}, 'formFlowController')
// Start managing the formformFlowController()See what happened here? We described a complex form flow with validation, submission, and success handling in a procedural way that’s easy to follow, yet it’s completely reactive. The code executes in response to events as they occur, without complex nested callbacks or state machine definitions.
Advanced Pattern: Condition-Based Event Sampling
Section titled “Advanced Pattern: Condition-Based Event Sampling”The take function allows for sophisticated filtering with its third parameter. This enables you to wait not just for events, but for events that meet specific criteria:
import { wrap, take } from '@reatom/core'
// Wait for a specific navigation eventconst routerAtom = atom('/home', 'router')const destination = await wrap( take(routerAtom, (path, skip) => (path === '/dashboard' ? path : skip)),)
// Wait for a specific form submissionconst formDataAtom = atom(null, 'formData')const validFormData = await wrap( take(formDataAtom, (data, skip) => (data?.isValid ? data : skip)),)This filtering capability eliminates the need for many conditional statements and allows for very declarative descriptions of complex flows.
Combining Multiple Sources: Racing and Parallel Sampling
Section titled “Combining Multiple Sources: Racing and Parallel Sampling”Sometimes you need to wait for any one of multiple possible events. Reatom makes this easy with techniques for racing between different events:
import { race, take, wrap, sleep } from '@reatom/core'
// Form submission atomsconst formSubmitSuccessAtom = atom(false, 'formSubmitSuccess')const cancelRequestedAtom = atom(false, 'cancelRequested')
const result = await wrap( race({ success: take(formSubmitSuccessAtom, (value) => value === true), cancel: take(cancelRequestedAtom, (value) => value === true), timeout: sleep(5000), }),)
if (result.success) { // Handle successful submission} else if (result.cancel) { // Handle cancellation} else if (result.timeout) { // Handle timeout}You can also wait for multiple events to occur in any order:
import { all, take, wrap } from '@reatom/core'
// Data loading atomsconst profileLoadedAtom = atom(null, 'profileLoaded')const preferencesLoadedAtom = atom(null, 'preferencesLoaded')
const [userProfile, userPreferences] = await wrap( all([ take(profileLoadedAtom, (profile) => profile !== null), take(preferencesLoadedAtom, (prefs) => prefs !== null), ]),)
// Both have loaded, proceedReal-world Example: User Auth Flow
Section titled “Real-world Example: User Auth Flow”Let’s bring everything together with a real-world example - an authentication flow that includes login, verification, and redirection:
import { atom, action, take, wrap, race, sleep, onEvent } from '@reatom/core'
// Auth state atomsconst userAtom = atom(null, 'user')const loadingAtom = atom(false, 'loading')const errorAtom = atom(null, 'error')const twofaRequiredAtom = atom(false, '2faRequired')
// Login actionconst login = action(async (credentials) => { return await wrap(api.login(credentials))}, 'login')
// 2FA verification actionconst verify2FA = action(async (userId, code) => { return await wrap(api.verify2FA(userId, code))}, 'verify2FA')
// Authentication flow controllerconst authFlow = action(async () => { // Create a login form and get reference to its submit button const loginForm = document.getElementById('loginForm') const submitButton = loginForm.querySelector('button[type="submit"]')
// Wait for login attempt (when submit button is clicked) await wrap(onEvent(submitButton, 'click'))
// Get form data const formData = new FormData(loginForm) const credentials = { username: formData.get('username'), password: formData.get('password'), }
loadingAtom.set(true)
try { // Attempt login const user = await wrap(login(credentials)) userAtom.set(user)
// If 2FA is required, wait for verification code if (user.requires2FA) { twofaRequiredAtom.set(true)
// Get reference to verification form const verificationForm = document.getElementById('2faForm') const verifyButton = verificationForm.querySelector( 'button[type="submit"]', )
// Wait for verification submission await wrap(onEvent(verifyButton, 'click'))
// Get verification code const verificationCode = document.getElementById('verificationCode').value
await wrap(verify2FA(user.id, verificationCode)) }
// Success! Wait for navigation or timeout const result = await wrap( race({ // Wait for a click on any navigation link navigation: onEvent(document.querySelector('nav'), 'click'), // Or timeout after 3 seconds timeout: sleep(3000), }), )
// Auto-redirect if user hasn't navigated manually if (result.timeout) { window.location.href = '/dashboard' } } catch (error) { errorAtom.set(error)
// Reset after error is acknowledged const dismissButton = document.getElementById('dismissError') await wrap(onEvent(dismissButton, 'click'))
errorAtom.set(null) } finally { loadingAtom.set(false) }}, 'authFlow')
// Start the auth flow controller when the app initializesdocument.addEventListener( 'DOMContentLoaded', wrap(() => { authFlow() }),)This example shows how Reatom allows you to describe complex, multi-step processes with branching logic in a way that’s readable, maintainable, and reactive.
Benefits Over Traditional Approaches
Section titled “Benefits Over Traditional Approaches”Compared to other approaches, Reatom’s sampling pattern offers significant advantages:
- Readability: Describe complex flows in a procedural style that’s easy to follow
- Maintainability: No deeply nested callbacks or complex state machines
- Flexibility: Combine reactive and imperative patterns seamlessly
- Type Safety: Full TypeScript support with excellent inference
- Testing: Easily isolate and test individual steps or entire flows
- Concurrency Control: Built-in handling of race conditions
Conclusion
Section titled “Conclusion”The unification of events and state through Reatom’s action and atom primitives enables a uniquely powerful approach to managing application state and behavior. By treating actions as reactive events and providing tools like take for procedural event sampling, Reatom creates a programming model that’s both more expressive and simpler than traditional approaches.
This pattern is especially valuable for:
- Complex user flows and multi-step processes
- Form validation and submission
- Authentication and authorization
- API request coordination
- Animation sequences
Next time you find yourself building complex state logic with multiple steps and conditions, consider how Reatom’s event sampling approach might help you create code that’s more maintainable and easier to reason about.
The power of reactive events awaits!