State is admittedly a crowded field. But for a while I've been supremely unhappy with hooks and redux (in all forms). I created Forestry
My unhappiness is rooted in several issues:
- State should not have to be asynchronous
- Validation should not be on the onus of the developer to implement
- State should be portable into a test context without the infrastructure of the view engine around it
- A single state system should work locally OR globally
- You should be able to nest change actions within change actions
- Even in nested actions you should have Transactional insulation - either all the sub-action code should succeed or none of it should
- You should have easy access to the historical state
In Detail:
State should not have to be asynchronous
There is no reason a state system should depend on the heartbeat of a view libraries' update cycle. If you change your state, you should be able to inspect the value of the state immediately after the change. To date, only Saga accomplishes this and you need to do some byzantine polling to get the value. With Forest, you get the value of the state immideately after changing the state:
const notDivByThrees = new Collection<number>('notDivByThrees', {
initial: 1}, {
increment() {
this.next(this.value + 1);
if (!this.value % 3)
this.next(this.value + 1);
}
})
const sub = notDivByThrees.subscribe((v) => console.log('NDBT:', v));
// NDBT: 1
notDivByThrees.acts.increment();
// NDBT: 2
notDivByThrees.acts.increment();
// NDBT: 4
direct inspection of values is much better than relying on hook listeners or effects to see what the value is. With setState
you get this wierd quasi-state of "I changed the value but thats all I can do, because it's in transition" and you must balkanize your code into side effect agents.
Validation should be implicit in the state system
In the absence of systemic validation, you essentially have to write validation in every change system to ensure your state validates to a particular schema. Typescript vastly reduces these issues but there are siginficant border conditions; for instance you cannot guarantee external systems will provide the content you expect.
Validation should be a built-in part of your state system - you should be able to just do an operation and expect it to throw
if you violate the schema of your state. (If you're using type guards you can simply throw if the typeguard returns false).
interface Product {
name: string;
id: string;
cost: number;
}
function isProduct(product: unknown): product is Product {
return (
typeof product === 'object' &&
product !== null &&
'name' in product &&
'id' in product &&
'cost' in product &&
typeof (product as { name: unknown }).name === 'string' &&
typeof (product as { id: unknown }).id === 'string' &&
typeof (product as { cost: unknown }).cost === 'number' &&
(product as { cost: number }).cost >= 0
);
}
function validProduct(product: unknown): asserts product is Product {
if (!isProduct(product)) {
throw new Error('Invalid product');
}
}
function validCart(items) {
if (!Array.isArray(items)) {
throw new Error('must be an array');
}
items.forEach(validProduct);
}
const api = {
async getCart(): Promise<Product[]> {
return [
{ id: 'tshirt', name: 'T Shirt', cost: 20 },
{ id: 'ybox', name: 'Y-box', cost: 200 },
{ id: 'pencil', name: 'Pencil', cost: 5 },
{ id: 'mtruck', name: 'Monster-Truck', cost: 50000 },
];
},
};
const cart = new Collection<Product[]>(
'cart',
{
initial: [],
validator: validCart,
},
{
applyDiscount({ id, amount }) {
const newProducts = this.value.map((product) => {
if (product.id === id) {
return { ...product, cost: product.cost - amount };
} else {
return product;
}
});
this.next(newProducts);
},
},
);
async function loadCart() {
const products = await api.getCart();
try {
cart.next(products);
} catch (err) {
window.alert('your products are not valid');
}
}
loadCart().then(() => {
try {
console.log('products loaded:', cart.value);
const saved = cart.acts.applyDiscount({ id: 'tshirt', amount: 5000 });
console.log('discount applied, saved', saved);
} catch (err) {
console.log('you cannot discount t-shirt by $5,000');
}
});
Yes - the application developer can always put careful checks in all their actions - but fundamentally you should be able to try to do a thing which should work mostly and watch for errors if it fails (and compensate). This is pretty standard dev practice and shouldn't have to be reinvented for every application context.
State should be portable
Forest uses class instances. They can be brought straight into a test scenario without the surrounding view context. While you can always "black box test" a component with a state, it is not necessary and you often sppend way more time wrestling with the dom layer than you should when all you want to do is to validate that your state is performing as designed.
The Documentation goes into far more depth around how to test forest components in isolation but its pretty transparent that when you have an object with methods and a value you can inspect, you don't need a lot of magic to develop unit tests around it. By contrast hooks are embedded in the view and not only a lot of work to test directly, but a lot of work to transport betweeen components. Hook design by its very nature tends to be very situation-specific and hard to transport between components.
State should work locally OR globally
When you have systems like Redux, it requires a whole pagentry of systems to bring into a local context. The Redux Toolkit makes it "less bad" but you should just be able to export a state from one module and import it into a local context. Period. There is zero reason why this should be complex.
React binding to a component requires a basic factory function for local states:
type StateValues = {
username: '',
password: '',
}
function LogIn() {
const state = useRef<TreeIF<StateValues>>(appState());
const [value, setValue] = useState(state.current.value);
useEffect(() => {
const sub = state.current?.subscribe((v) => setValue(v));
() => sub?.unsubscribe();
}, []);
return (
<div>
<div>
<label>Username</label>
<input name="username" value={value.username}
onChange={(e) => state.next({...state.value, username: e.target.value}))} />
</div>
<div>
<label>Password</label>
<input name="password" type="password" value={value.password}
onChange={(e) => state.next({...state.value, password: e.target.value}))} />
</div>
</div>
)
}
function appState () {
const f = new Forest();
t = f.addTree<StateValues>('login', {
initial: {username: '', password: ''}
})
return tree;
}
To use a global state you just import it - no fuss, one less ref:
import {state, type StateValues} from '/lib/state';
function LogIn() {
const [value, setValue] = useState(state);
useEffect(() => {
const sub = state.subscribe((v) => setValue(v));
() => sub?.unsubscribe();
}, []);
//...
Using arcane complex state mechanics gets you used to headstands - there is no reason
you need to do this if your system is self contained and portable.
You should be able to call change methods from other change methods
Nested logic is a fundamental of application design. Redux and hooks tend to "flatten out" your design process in a way that is unnatural and limiting. In part this is a side effect of delayed action.
In the cart example above say you have a discount that is percent based for the whole cart but has a maximum discount value.
import {api, Product} from './cartApi';
const cart = new Collection<Product[]>(
'cart',
{
initial: [],
validator: validCart,
},
{
applyDiscount({ id, amount }) {
const newProducts = this.value.map((product) => {
if (product.id === id) {
return { ...product, cost: product.cost - amount };
} else {
return product;
}
});
this.next(newProducts);
},
mostExpensiveProducts() {
return sortBy(this.value, 'cost').reverse();
},
discountedProduct({ id, amount }: { id: string; amount: number }) {
// doesn't change state - just returns a candidate
const product = this.acts
.mostExpensiveProducts()
.find((p) => p.id === id);
return { ...product, cost: product.cost - amount };
},
percentDiscount({
percent,
maxSaving,
}: {
percent: number; // amount off 0..1
maxSaving: number;
}) {
// should validate rational values of percent/maxDate
let saved = 0;
for (const p of this.acts.mostExpensiveProducts()) {
let discount = p.cost * percent;
if (saved + discount > maxSaving) {
discount = maxSaving - saved;
}
saved += discount;
const discountedProduct = this.acts.discountedProduct({
id: p.id,
amount: discount,
});
this.acts.updateProduct(discountedProduct);
if (saved >= maxSaving) {
break;
}
}
return saved;
},
updateProduct(product: Product) {
// should validate existence in list
this.next(
this.value.map((p) => (p.id === product.id ? product : p)),
);
},
},
new Forest()
);
async function loadCart() {
const products = await api.getCart();
try {
cart.next(products);
} catch (err) {
window.alert('your products are not valid');
}
}
loadCart().then(() => {
try {
console.log('products loaded:', cart.value);
const saved = cart.acts.applyDiscount({ id: 'tshirt', amount: 5000 });
console.log('discount applied, saved', saved);
} catch (err) {
console.log('you cannot discount t-shirt by $5,000');
}
const discount = { percent: 0.2, maxSaving: 10020 };
const saving = cart.acts.percentDiscount(discount);
console.log(
'--- after ',
100 * discount.percent,
'% savings up to ',
discount.maxSaving,
'saved',
saving,
cart.value,
);
});
/**
products loaded: [
{ id: 'tshirt', name: 'T Shirt', cost: 20 },
{ id: 'ybox', name: 'Y-box', cost: 200 },
{ id: 'pencil', name: 'Pencil', cost: 5 },
{ id: 'mtruck', name: 'Monster-Truck', cost: 50000 }
]
you cannot discount t-shirt by $5,000
--- after 20 % savings up to 10020 saved 10020 [
{ id: 'tshirt', name: 'T Shirt', cost: 20 },
{ id: 'ybox', name: 'Y-box', cost: 180 },
{ id: 'pencil', name: 'Pencil', cost: 5 },
{ id: 'mtruck', name: 'Monster-Truck', cost: 40000 }
]
*/
You should get transactional insulation from partial changes
One of the downsides of nested actions is that you can get halfway through a set of changes and choke. Either the entire action should complete, or the state should be reset to the starting condition.
The Documentation demonstrates this in action - its a difficult concept requiring verbose code to see in action.
You should be able to see the state change in time
Forestry gives you built-in journaling of the state. Multiple collections from the same Forest will have time stamps consistent across the system. You can find the "current time" off of myTree.forest.time
(or myCollection.tree.forest.time
) and call myTree.valueAt(number)
to see a tree's historical value at any time. This requires browser plugins for redux and is simply not possible with hooks, but it gives you a builtin diagnostic tool to figure out how and why your state got into the - uh - state it's in, in the event of strange behavior.
The Manual
The Forestry Site describes in detail how you use Forest as a state system. It uses and is based on the standards of RxJS which should be looked into for its own value; but RxJS is by design not tooled to providing action or specific validation in its streams, and this is where Forestry comes in. Forestry is the third version of Forest (Forest - three) that has been used and tested extensively for web applications.
Top comments (0)