Thomas Burleson PRO
FE Architect, Technical Lead, and Engineering Coach. Delivering web solutions using React, NextJS, Angular, and TypeScript.
All Rights Reserved
Copyright 2021, 2022
Mindspace, LLC
Part 2
"A program is said to be reactive when an input change leads to a corresponding change in output without any need to update the output change manually."
Reactive Programming
Reactive for All Layers
Business
Data
Services
Business
Hooks
UI
View Models
Reactive Pathways
?
?
What are the paths to building Reactive solutions ?
{
Framework makes this "easy"
{
Where the hard architecture work is done
Reactive Libraries
Reactive Stores
Part 2 of 5
Reactive Stores ?
Why use
Store
Reactive Stores ?
Why use
No need to understand artifacts and mechanics of complex state management like NgRx.
Reactive Stores ?
Why use
Reactive Stores
Simple API
The store you define is a mix of state and APIs. This is your full view model.
Your hook has built-in change detection
Select the entire store or optimize rendering with selectors to request a custom VM
View Model state is always immutable; to enforce 1-way data flows.
Steps to build a Reactive Store:
import { createStore } from '@mindspace-io/react';
// Define a factory to create your custom store; which
// will be managed by the internal Reactive store
const factoryFn = ( api: StoreAPI ) => {
return <store>; // aka View Model
}
// Build a React hook to a managed store
const useStore : HookAPI = createStore(
factoryFn : StateCreator,
options ?: Options
): HookAPI { }
// Access read-only store.
// May Use opitonal selectors to build viewModel
// and optimize change detection
const {} = useStore();
Simple Counter
Reactive Store Demo:
React
Define your store API:
import { State } from '@mindspace-io/react';
export interface CounterStore extends State {
count: number;
incrementCount: () => void;
decrementCount: () => void;
}
Define the store Factory:
import { StoreAPI } from '@mindspace-io/react';
import type { CounterStore } from './counter.interfaces';
const factory = (api: StoreAPI<CounterStore> ): CounterStore => ({
count: 0,
incrementCount() { },
decrementCount() { },
});
counter.store.ts
StoreAPI<T>:
export interface StoreApi<T extends State> {
set: SetState<T>;
get: GetState<T>;
// Used to batch state changes
applyTransaction: ApplyTransaction<T>;
// Used during store configuration
addComputedProperty: AddComputedProperty<T>;
watchProperty: WatchProperty<T>;
// Used to announce status
setIsLoading: SetLoading;
setError: SetError;
}
This API is accessible ONLY within your factory function used with createStore(<factoryFn>).
@mindspace-io/react
Use API within the store Factory:
import { StoreAPI } from '@mindspace-io/react';
import type { CounterStore } from './counter.interfaces';
const factory = (api: StoreAPI<CounterStore> ): CounterStore => ({
count: 0,
incrementCount() {
api.set((draft) => {
draft.count += 1;
});
},
decrementCount() {
api.set((draft) => {
draft.count -= 1;
});
},
});
counter.store.ts
Under the hood with the store createStore:
import { StoreAPI } from '@mindspace-io/react';
import type { CounterStore } from './counter.interfaces';
const factory = (api: StoreAPI<CounterStore> ): CounterStore => ({
count: 0,
incrementCount() {
api.set((draft) => {
draft.count += 1;
});
},
decrementCount() {
api.set((draft) => {
draft.count -= 1;
});
},
});
Use createStore for a custom hook:
import { createStore, StoreAPI } from '@mindspace-io/react';
import type { CounterStore } from './counter.interfaces';
const factoryFn = (api: StoreAPI<CounterStore> ): CounterStore => ({
count: 0,
incrementCount() { /*...*/ },
decrementCount() { /*...*/ },
});
export const useCounterStore = createStore( factoryFn )
createStore() returns a custom hook that is automatically connected to the reactive store.
To access the view model, use the hook...
counter.store.ts
Use custom hook in a Functional Component:
import React, { useCallback } from 'react';
import { useCounterStore, CounterStore } from './counter.store';
export const SimpleCounter: React.FC = () => {
const { count, incrementCount } = useCounterStore<CounterStore>();
return (
<>
<p> Count = {count} </p>
<button onClick={incrementCount}>Increment</button>
</>
);
};
SimpleCounter.tsx
Reactive Store
(with Immer, RxJS, and Akita)
import { createStore } from "@mindspace-io/react";
export const useStore = createStore<CounterStore>(({ set }) => {
const store = ({
visits : 0,
messages: [],
incrementCount() { set((d) => { d.visits += 1;}) },
decrementCount() { set((d) => { d.visits -= 1; }) }
});
return store;
});
Traditional Approach
(with React Hooks)
import { useState, useCallback } from "react";
export function useCounter(): CounterStore {
const [visits, setVisits] = useState(0);
const [messages] = useState([]);
const incrementCount = useCallback(() => {
setVisits((prev) => prev + 1);
}, [setVisits]);
const decrementCount = useCallback(() => {
setVisits((prev) => prev - 1);
}, [setVisits]);
return {
visits,
messages,
incrementCount,
decrementCount
};
}
Publish hook
Immutable State
Hook accepts selectors
Uses drafts to mutate internally
State is now inherently observable
`useStore(<selector>)` decides what state is desired
State is shared across components
Mutable State
Hook always publishes same structure
State is NOT shared across components
Async + Computed Properties
React
Reactive Store Demo:
Define your store API:
import { State } from '@mindspace-io/react';
export interface MessageStore extends State {
filterBy : string;
messages : string[];
filteredMessages : string[];
updateFilter : (filterBy: string) => void;
}
Implement the store API:
import { StoreEffect } from '@mindspace-io/react';
import type { MessageStore } from './message.store.interfaces';
const factoryFn = ({ set, addComputedProperty }, useStoreEffect: StoreEffect) => {
// Create store with API and initializations
const store = {
filterBy : '',
messages : [], // all messages
filteredMessages : [], // only messages with filte matches
updateFilter(filterBy: string) {
set((s) => {
s.filterBy = filterBy;
});
}
};
//useStoreEffect(() => { }, []);
//return addComputedProperty();
}
message.store.ts
StoreEffect:
import { DependencyList, EffectCallback } from 'react';
/**
* Feature to enables stores to internal perform
* initialization side affect AFTER store is ready
*/
export type StoreEffect = (
effect : EffectCallback,
deps ?: DependencyList
) => void;
This effect is accessible ONLY within the createStore(<factoryFn>).
@mindspace-io/react
Use the StoreEffect:
import { StoreEffect } from '@mindspace-io/react';
import { MessageService } from './messages.service';
import type { MessageStore } from './message.store.interfaces';
const service = new MessageService();
const factoryFn = ({ set, addComputedProperty }, useStoreEffect: StoreEffect) => {
// Create store with API and initializations
const store = { /*...*/ ;
// Async side effect to 1x load all messages
useStoreEffect(() => {
service.loadAll().then((messages) => {
set((d) => {
d.messages = messages;
});
});
}, []);
return addComputedProperty( /*..*/ );
}
message.store.ts
Add a Computed Property
import { StoreEffect } from '@mindspace-io/react';
import { onlyFilteredMessages } from './messages.utils';
import { MessageService } from './messages.service';
import type { MessageStore } from './message.store.interfaces';
//const service = new MessageService();
const factoryFn = ({ set, addComputedProperty }, useStoreEffect: StoreEffect) => {
const store = { /*...*/ ;
useStoreEffect(() => { /*...*/ }, []);
// Create a computed property dependent on two (2) queries
// and chain a transformation operator to add search match highlights
return addComputedProperty(store, {
name: 'filteredMessages',
selectors: [(s: MessagesState) => s.messages, (s: MessagesState) => s.filterBy],
transform: onlyFilteredMessages,
});
}
message.store.ts
Define Transform Operator
type QueryResults = [string[], string]; // Tuple
type MessageList = string[];
const injectHighlights = (match: string) => (s: string) => {
return s.replace(new RegExp(filterBy, 'gi'), (match) => `<span class='match'>${match}</span>`);
}
const hasCritiera = (filterBy: string) => (s: string) => {
return s.toLowerCase().indexOf(criteria) > -1;
}
/**
* Build computed property for `filteredMessages`
* Uses an array of selectors [<all messages>, <filterBy criteria>]
* for optimized change propagation
*/
export const onlyFilteredMessages = ([messages, filterBy]: QueryResults): MessageList => {
const criteria = filterBy.toLowerCase();
return !filterBy ? [...messages] : messages
.filter( hasCritiera(criteria) )
.map( injectHighlights(match) );
};
message.utils.ts
Use createStore for a custom hook:
import { createStore, StoreEffect } from '@mindspace-io/react';
import { MessageService } from './messages.service';
import { MessagesState } from './messages.interfaces';
import { onlyFilteredMessages } from './messages.utils';
const service = new MessageService();
/*******************************************
* Instantiate store with state
* Note: The `filteredMessages` value is updated via a 'computed' property
*******************************************/
export const useMessageStore = createStore<MessagesState>( /* ... */ );
To access the view model, use the hook...
To build a custom ViewModel, use a Hook Query!
message.store.ts
Define a custom View Model + Query
import { State, QueryList } from '@mindspace-io/react';
export interface MessageStore extends State {
filterBy : string;
messages : string[];
filteredMessages : string[];
updateFilter : (filterBy: string) => void;
}
// Define a custom Message View Model. (Btw, this is a Typle)
//
export type ViewModel = [string, string[], (v: string) => void];
// Define a Hook Query to select the following store properties
//
export const storeQuery = QueryList<MessageStore, ViewModel>[
(s: MessagesState) => s.filterBy,
(s: MessagesState) => s.filteredMessages,
(s: MessagesState) => s.updateFilter,
];
message.interfaces.ts
Use custom hook in a Functional Component:
import React from 'react';
import { storeQuery } from './messages.interfaces';
import { useMessageStore } from './messages.store';
/**
* Show list of messages that container the searchCriteria
*/
export const FilteredMessages: React.FC = () => {
const [searchCriteria, messages, updateSearchBy] = useMessageStore(storeQuery);
return (
<>
// See live demo...
</>
)
};
FilteredMessages.tsx
Reactive Store
(with Immer, RxJS, and Akita)
Traditional Approach
(with React Hooks)
import { createStore } from "@mindspace-io/react";
import { onlyFilteredMessages, MessageStore } from "./common";
export const useStore = createStore<MessageStore>(
({ set, addComputedProperty }) => {
const store = {
filterBy: "",
messages: [],
updateFilter(filterBy: string) {
set((s) => {
s.filterBy = filterBy;
});
},
};
return addComputedProperty(store, {
name: "filteredMessages",
selectors: [
(s: MessagesState) => s.messages,
(s: MessagesState) => s.filterBy
],
transform: onlyFilteredMessages
});
}
);
import { useState, useEffect } from "react";
import { onlyFilteredMessages, ViewModel, MESSAGES } from "./common";
export function useMessages(): ViewModel {
const [allMessages] = useState(MESSAGES);
const [filterBy, updateFilter] = useState("");
const [messages, setMessages] = useState(allMessages);
useEffect(() => {
const filtered = onlyFilteredMessages([allMessages, filterBy]);
setMessages(filtered);
},[allMessages, filterBy])
return [
filterBy,
messages,
updateFilter
];
}
Full Store API access
Immutability Guaranteed
Store-like API easily managed.
Mutable Risks
Multiple re-Renders
"Feels" Clumsy
More features
Transactions
Text
const useStore = createStore<MessageStore>((
{ set, setIsLoading, applyTransaction }) => {
return {
messages : [],
async refresh() {
applyTransaction(() => {
setIsLoading();
set((s) => { s.messages = [] });
});
const messages = await emailService.loadAll();
applyTransaction(() => {
set((s) => {
s.messages = messages;
s.isLoading = false;
});
});
},
};
});
More features
View Facades
Text
export const useTodoStore = createStore<TodoStore>(({set}) => {
return ({
todos: [],
filter: VISIBILITY_FILTER.SHOW_ALL,
facade: {
async addTodo(text: string): boolean { },
async deleteTodo({ id }): boolean { },
toggleComplete({ id }) { },
updateFilter(filter: VISIBILITY_FILTER) { },
history: {
undo: () => {},
redo: () => {},
hasPast: false,
hasFuture: false
}
}
});
});
View Facades
Text
import { useTodoStore, TodoStore } from '@customer/todo-store';
export const TodosPage: React.FC = () => {
const { todos, filter, facade } = useTodoStore<TodoStore>();
return (
<>
<div className="todoBar">
<AddTodo onAdd={ facade.addTodo } />
<Filters selectedFilter={ filter }
onChange={ facade.updateFilter } />
</div>
<TodoList todos={ todos }
onToggle={ facade.toggleComplete }
onDelete={ facade.deleteTodo }
/>
</>
);
};
Demo
More features
Store Query
export type QueryResults = [Todo[], TodoFacade];
export const query: QueryList<TodoStore, QueryResults>[] = [
(store) => store.todos,
(store) => store.facade
];
export const TodoList: FC = () => {
const [todos, facade] = useTodoStore<StoreViewModel>(query);
return ( <div> ... </div> );
};
When using a store query (aka selector), a Tuple is returned instead of the store object.
within the React Platform
React Hooks
Reactive Stores
within the React Platform
React Hooks
Reactive Stores
within the React Platform
React Hooks
Reactive Stores
within the React Platform
React Hooks
Reactive Stores
within the React Platform
React Hooks
Reactive Stores
within the React Platform
React Hooks
Reactive Stores
within the React Platform
React Hooks
Reactive Stores
... more Libraries
By Thomas Burleson
FE Architect, Technical Lead, and Engineering Coach. Delivering web solutions using React, NextJS, Angular, and TypeScript.