deep-state
v1.0.0-beta.9
Published
Easy to use state controllers using classes and hooks
Downloads
6
Readme
Contents
• Overview • Install and Import
Getting Started
• The basics • Destructuring • Methods • Getters • Constructor • Applied Props
Dynamics • Lifecycle • Events ◦ Listening ◦ Dispatching • Monitored Externals • Async and callbacks
Sharing • Context ◦ Provider ◦ MultiProvider • Singletons
Accessing
• Hooks
◦ get
(unbound)
◦ tap
(one-way)
◦ sub
(two-way)
Applied State
• Managed Elements
◦ Value
◦ Input
Structuring • Applied Typescript • Simple Composition • Child Controllers • Peer Controllers
Extension • Super-Controllers • Using Meta
API
• Controller • Singleton • Patterns • Reserved • Lifecycle
Internal Concepts
• Subscriptions • Auto Debounce
With deep-state, you can create and use javascript classes as controllers (via hooks) within any, or many, React components.
When built-in methods on a Controller
class are used, an instance is either found or created, specially for the mounted component (your View). By noting what's used at render, the hook (a Controller) can keep values up-to-date, following properties defined by your class (the Model).
This behavior combines with actions, computed properties, events, and the component itself allowing for a (real this time) Model-View-Controller development pattern.
Install with your preferred package manager
npm install --save deep-state
Import and use in your react (or preact!) apps.
import VC from "deep-state";
Note:
VC
here is short for (View)Controller
, which is the default export.
The basic workflow is pretty simple. If you know MobX this will look pretty familiar, but also a lot more straight-forward.
- Create a class and fill it with the values, getters, and methods you'll need.
- Extend
Controller
(or any derivative, for that matter) to make it "observable". - Within a component, call one of the built-in methods, as you would any React hook.
- Destructure out values, in a component, for controller to detect and subscribe to.
- Update those values on demand. Your component will keep sync automagically. ✨
Some Definitions
The following is a guided crash-course to help get you up to speed (hopefully) pretty quick. Here are some library-specific terms which will be good to know.
VC
: Alias forController
, the core class powering most of deep-state.- Model: Any class you'll write extending
VC
; the definition for a type of controller. - State: An instance of your model, usable to a live component.
- Controller: The logic (inherited from
VC
) in an instance of state, managing its behavior. - View: A defined function-component which may be mounted and can accept hooks.
- Element: Invocation of a component/view, actively mounted with a state and lifecycle.
- Subscription: An open channel to deep-state's communication engine, managing events.
Let's make a stateful counter.
import VC from "deep-state";
class Counter extends VC {
number = 1
}
const KitchenCounter = () => {
const state = Counter.use();
return (
<div>
<span
onClick={() => state.number -= 1}>
{"−"}
</span>
<pre>{ state.number }</pre>
<span
onClick={() => state.number += 1}>
{"+"}
</span>
</div>
)
}
View in CodeSandbox
Make a class with properties we wish track. Values defined in the constructor (or as class properties) serve as initial/default state.
Attached to your class is the static method use
. This is a hook; it will create a new instance of your state and bind it to a component.
Now, as values on this instance change, our hook will trigger new renders! You may recognize this as "one-way binding".
Because of how subscriptions work, a good idea is to destructure values intended for the component. To cover normal pitfalls, you'll see a set
and get
added to the returned state.
Not to be confused with the keywords. Just properties, they are both a circular reference to state.
set
The usual downside to destructuring is you can't use it for assignments. To solve this, we have set
for updating values on the full state.
const KitchenCounter = () => {
const { number, set } = Counter.use();
return (
<div>
<span
onClick={() => set.number -= 1}>
{"−"}
</span>
<pre>{ number }</pre>
<span
onClick={() => set.number += 1}>
{"+"}
</span>
</div>
)
}
set.number
See what we did there? 🤔
get
Good for bracket-notation (i.e. get["property"]
), and avoiding clutter where necessary.
Also, the main way to ignore updates.
Usually, when you read an observable value directly, a controller will assume you want to refresh anytime that property changes. In a lot of situations, this isn't the case, and so get
serves as a bypass.
Use this when using values from inside a closure, such as callbacks and event-handlers.
What's a controller without some methods? Let's add some actions (similar to that of MobX) to easily abstract changes to our state.
class CountControl extends VC {
number = 1
// Note that we're using arrow functions here.
// We'll need a bound `this`.
increment = () => { this.number++ };
decrement = () => { this.number-- };
}
You may notice this approach is also more efficient. These handlers won't make new closures every time we render now. 😬
const KitchenCounter = () => {
const { number, decrement, increment } = CountControl.use();
return (
<Row>
<Button onClick={decrement}>{"-"}</Button>
<Box>{number}</Box>
<Button onClick={increment}>{"+"}</Button>
</Row>
)
}
With this you can write even the most complex components, all while maintaining key benefits of a functional-component, being much easier on the eyeballs.
Deep-state does have a strong equivalent to computed properties (ala MobX again).
Simply define the getters you need and they will be automatically managed by the controller. Computed when first accessed, they will be actively kept in-sync thereafter.
Through the same mechanism as hooks, getters know when properties they access are updated. Whenever that happens, they rerun. If a new value is returned, it will be passed forward to own listeners.
const round = Math.floor;
class Timer extends VC {
seconds = 0;
constructor(){
super();
setInterval(() => this.seconds++, 1000);
}
get minutes(){
return round(this.seconds / 60);
}
get hours(){
// getters can also subscribe to other getters 🤙
return round(this.minutes / 60);
}
get format(){
const { seconds } = this;
const hr = round(seconds / 3600);
const min = round(seconds / 60) % 60;
const sec = seconds % 60;
return `${hr}:${min}:${sec}`;
}
}
Important Caveat: Controller getters are cached, facing the user. They will only run when a dependency changes, and not upon access (besides initially) as you might except.
Getters run whenever the controller thinks they could change, so design them with three guiding principles:
- Getters should be deterministic. Only expect a change where inputs have changed.
- Avoid computing from values which change a lot, but don't affect output as often.
- GWS but, side-effects are a major anti-pattern, and could cause infinite loops.
The method use(...)
, as it creates the control instance, will pass its own arguments to the class's constructor. This makes it easy to customize the initial state of a component.
Typescript
class Greetings extends VC {
firstName: string;
constructor(name: string){
super();
this.firstName = name.split(" ")[0];
}
}
const MyComponent = ({ name }) => {
const { firstName } = Greetings.use(name);
return <b>Hello {firstName}!</b>;
}
Besides use
, there are similar methods able to assign props after a controller is created. This is a great alternative to manually distributing values, as we did in the example above.
After constructing state, something similar to Object.assign(this, input)
is run. However based on value of greedy
, this will have one of three biases.
- Default: greedy is undefined
- only properties already
in
state (as explicitlyundefined
or some default value) will be captured
- only properties already
- If greedy is true
- all properties of
input
will be added, and made observable if not already.
- all properties of
- If greedy is false
- only properties explicitly
undefined
on state (just after construction) will be overridden.
- only properties explicitly
class Greetings extends VC {
name = undefined;
birthday = undefined;
get firstName(){
return this.name.split(" ")[0];
}
get isBirthday(){
const td = new Date();
const bd = new Date(this.birthday);
return (
td.getMonth() === bd.getMonth() &&
td.getDate() === bd.getDate()
)
}
}
const HappyBirthday = (props) => {
const { firstName, isBirthday } = Greetings.uses(props);
return (
<big>
<span>Hi {firstName}<\span>
{isBirthday &&
<b> happy birthday!</b>
}!
</big>
);
}
const SayHello = () => (
<HappyBirthday
name="John Doe"
birthday="September 19"
/>
)
✅ Level 1 Clear!
In this chapter we learned the basics of how to create and utilize a custom state. For most people, who simply want smart components, this could even be enough! However, we can make our controllers into much more than just some fancy hooks.
So far, all of our example controllers have been passive. Here we'll give our controller a bigger roll, by pushing updates without direct user interaction.
Because state is just a class-instance, we can do whatever we want to values, and more-crucially, whenever. This makes asynchronous coding pretty low maintenance. We handle the logic of what we want and Controller
will handle the rest.
Here are a few concrete ways though, to smarten up your controllers:
Deep-state hooks can automatically call a number of "special methods" you'll define on your model, to handle certain "events" within components.
class TimerControl extends VC {
elapsed = 1;
componentDidMount(){
this.timer =
setInterval(() => this.elapsed++, 1000)
}
/** remember to cleanup ♻ */
componentWillUnmount(){
clearInterval(this.timer);
}
}
De ja vu... could swear that looks awfully familiar... 🤔
const MyTimer = () => {
const { elapsed } = TimerControl.use();
return <pre>{ elapsed } seconds sofar</pre>;
}
You can see all the available lifecycle methods here.
Beyond watching for state-change, what a subscriber really cares about is events. Updates are just a cause for an event. Whenever a property on your state gains a new value, subscribers simply are notified and act accordingly.
While usually it'll be a controller waiting to refresh a component, anything can subscribe to an event via callbacks. If this event is caused by a property update, its new value will serve as an argument
; if synthetic, that will be up to the dispatcher.
Assumes the following callback
const callback = (value, name) => {
console.log(`${name} was updated with ${value}!`)
}
Instances of Controller
have the following methods added in for event handling:
.on(name, callback) => onDone
This will register a new listener on a given key. callback
will be fired when the managed-property name
is updated, or when a synthetic event is sent.
The method also returns a callback, by which you can stop subscribing.
Note: you will not need to cleanup events at
willDestroy
orcomponentWillUnmount
, as listeners will be stopped naturally.
.once(name, callback) => onCancel
Same as on
, however will delete itself after being invoked. You can cancel it with the returned callback.
.once(name) => Promise<value>
If callback
is not provided, once
will return a Promise instead, which resolves the next value (or argument) name
receives.
.watch(arrayOfNames, callback, once?) => onDone
A more versatile method used to monitor one or multiple keys with the same callback.
Controllers will also dispatch lifecycle events for themselves and that of their bound components.
All events share names with their respective methods, listed here.
.update(name, argument?)
Fires a synthetic event; it will be sent to all listeners of name
, be them subscribed controllers or one of the listeners above.
This can have slightly different behavior, depending on the occupied-status of a given key.
- no property exists:
- Explicit subscribers will receive the event; controllers cannot.
- Property exists:
- no argument: Subscribers will force-refresh, listeners will get current value.
- has argument: Property will be overwritten, listeners get new value.
- property is a getter:
- no argument: Getter will force-compute, listeners get output regardless if new.
- has argument: Cache will be overwritten (compute skipped), listeners get said value.
Events make it easier to design around closures, keeping as few things on your model as possible. Event methods can also be used externally, for other code to interact with as well.
Event handling in-practice:
class Counter extends VC {
seconds = 0;
alertMinutes = (minutes) => {
alert(`${minutes} minutes have gone by!`)
}
tickTock = (seconds) => {
if(seconds % 2 == 1)
console.log("tick")
else
console.log("tock")
if(seconds % 60 === 0){
// send minute event (with optional argument)
this.update("isMinute", Math.floor(seconds / 60));
}
}
componentDidMount(){
const timer = setInterval(() => this.seconds++, 1000);
const timerDone = () => clearInterval(timer);
// run callback every time 'seconds' changes
this.on("seconds", this.tickTock);
// run callback when minute event is sent out
this.on("isMinute", this.alertMinutes);
// run callback when unmount is sent by controller
// using events, we avoid needing another method, for just this
this.once("componentWillUnmount", timerDone);
}
}
Sometimes, you may want to detect changes in some outside-info, usually props. Watching values outside a controller does require you integrate them, as part of your state; however we do have a handy helper for this.
If you remember
uses
, this is somewhat equivalent.
This method helps integrate outside values by repeatedly assigning input
properties every render. Because the observer will only react to new values, this makes for a fairly clean way to watch props. We can combine this with getters and event-listeners, to do all sorts of things when inputs change.
Like uses
, this method is naturally picky and will only capture values which exist on our state at launch. We do have a greedy
flag though, which works the same.
class ActivityTracker {
active = undefined;
get status(){
return this.active ? "active" : "inactive";
}
componentDidMount(){
this.on("active", (yes) => {
if(yes)
alert("Tracker prop became active!")
})
}
}
const DetectActivity = (props) => {
const { status } = ActivityTracker.using(props);
return (
<big>
This element is currently {status}.
</big>
);
}
const Activate = () => {
const [isActive, setActive] = useState(false);
return (
<div onClick={() => setActive(!isActive)}>
<DetectActivity active={isActive} />
</div>
)
}
Like this, we can freely interact different sources of state.
Because dispatch is taken care of, all we need to do is edit values. This makes the asynchronous stuff like timeouts, promises, and fetching a piece of cake.
class StickySituation extends VC {
remaining = 60;
agent = "Bond";
componentDidMount(){
const timer =
setInterval(this.tickTock, 1000);
// here's how-to use watch!
this.watch(
["stop", "componentWillUnmount"],
() => clearInterval(timer)
);
}
tickTock = () => {
const timeLeft = --this.remaining;
if(timeLeft === 0)
this.update("stop");
}
getSomebodyElse = async () => {
const res = await fetch("https://randomuser.me/api/");
const data = await res.json();
const [ recruit ] = data.results;
this.agent = recruit.name.last;
}
}
const ActionSequence = () => {
const {
getSomebodyElse,
remaining,
agent
} = StickySituation.use();
if(remaining === 0)
return <h1>{"🙀💥"}</h1>
return (
<div>
<div>
<b>Agent {agent}</b>, we need you to diffuse the bomb!
</div>
<div>
If you can't diffuse it in {remaining} seconds,
Schrodinger's cat may or may not die!
</div>
<div>
But there is time!
<u onClick={getSomebodyElse}>Tap another agent</u>
if you think they can do it.
</div>
</div>
)
}
👾 Level 2 Clear!
Sidebar, notice how our component remains completely independent from the logic sofar; it's a pretty big deal.
If we want to modify or even duplicate our
ActionSequence
, says in a different language or with a new aesthetic, we don't need to copy, or even edit, any of these actual behaviors. 🤯
One of the most important features of deep-state is an ability to share state with any number of subscribers, be them components or peer-controllers. Whether you want state from up-stream or to be usable app-wide, you can with a number of simple abstractions.
In this chapter we will cover how to create and cast state for use by components and peers. It's in the next chapter though, where we'll see how to access them.
By default, a Controller
is biased towards context as it's sharing mechanism. You probably guessed this, but through a managed React Context can we create and consume a single state throughout a component hierarchy.
Let's go over the ways to create a controller and insert it into context, for more than one component. There is nothing you need to do on the model to make this work.
export class Central extends VC {
foo = 0;
bar = 0;
fooUp = () => this.foo++;
};
We start with a sample controller class, nothing too special. We'll be reusing it for the following examples.
Another reserved property on a controller instance is Property
. Within a component, this will be visible. Wrap this around elements returned by your component, to declare your instance of state for down-stream.
export const App = () => {
const { Provider } = Control.use();
return (
<Provider>
<InnerFoo/>
<InnerBar/>
</Provider>
)
}
We'll assume you don't need special construction, or any of the values within parent component. With the Provider
class-property, you can create both new a state and its context provider in one sitting.
export const App = () => {
return (
<Control.Provider>
<InnerFoo/>
<InnerBar/>
</Control.Provider>
)
}
While context is recommended ensure reusability, very often we'll want to assign just one controller to a particular domain. Think concepts like Login, Settings, and interacting with outside APIs.
Here we introduce a new class of controller called a Singleton
(or GC
for short). With it we can create shared state without caring about hierarchy! Access hooks work exactly the same as their Controller
counterparts, except under the hood they use a single promoted instance.
Creating a Global Instance
Singletons will not be useable until state is initialized in one of three ways.
Consider the following model
import { GC } from "deep-state";
class Login extends GC {
thinking = false;
loggedIn = false;
userName = undefined;
allowances = [];
/** try to recall a session from cookies. */
async tryResume(){
this.thinking = true;
if(true){
this.loggedIn = true;
userName = "John Doe";
allowances = ["admin"];
}
this.thinking = false;
}
}
Note: Example makes use of
state.assign({})
which is defined here.
We can make this class available in the following ways.
Use a
.create()
method built-in toSingleton
. This can be done anywhere as long as it's before a dependant (component or peer) tries to access from it.window.addEventListener("load", () => { const userLogin = Login.create(); // Login singleton now exists and is usable anywhere. userLogin.tryResume(); // Good time to fire off a background task or two. ReactDOM.render(<App />, document.getElementById("root")); });
Create an instance with any one of our normal
use
methods.const LoginPrompt = () => { const { loggedIn, get } = Login.use(); return loggedIn ? <LoginPrompt onClick={() => get.tryResume()} /> : <Welcome name={get.userName} /> }
Login instance will be freely accessible after
use()
invokes. Note that instance will become unavailable ifLoginPrompt
does unmount. Likewise, ifLoginPrompt
mounts again, any newly rendered dependents get the latest instance.Mount its Provider
export const App = () => { return ( <Login.Provider> <UserInterface /> </Login.Provider> ) }
This has no bearing on context, it will simply be "provided" to everyone!
Let's recall our example controller we defined up above.
export class Central extends VC {
foo = 0;
bar = 0;
fooUp = () => this.foo++;
};
Whether our model is that of a Controller or Singleton will not matter for this exercise. They both present the same, to you the user!
With the method
.tap
, rather than making a newCentral
controller, will obtain the nearest one
const InnerFoo = () => {
const { fooUp, bar } = Central.tap();
return (
<div onClick={fooUp}>
<pre>Foo</pre>
<small>Bar was clicked {bar} times!</small>
</div>
)
}
Remember: Controller knows this component needs to update only when foo changes. Lazy subscription ensures only the properties accessed here are refreshed here!
const InnerBar = () => {
const { set, foo } = Central.tap();
return (
<div onClick={() => set.bar++}>
<pre>Bar</pre>
<small>Foo was clicked {foo} times!</small>
</div>
)
}
Another core purpose of deep-state, and using classes, is to "dumb down" the state you are writing. Ideally we want controllers to be really good at one thing, and be able to cooperate with other controllers as an ecosystem.
This is how we'll build better performing, easier to-work-with applications, even with the most complex of behavior.
Here we'll go over, in broad strokes, some of the ways to structure state harmoniously.
Remember to code responsibly. This goes without saying, but typescript is your friend. With controllers you can enjoy full type safety and inference, even within components themselves.
Typescript
import Controller from "deep-state";
class FunActivity extends VC {
/** Interval identifier for cleaning up */
interval: number;
/** Number of seconds that have passed */
secondsSofar: number;
constructor(alreadyMinutes: number = 0){
super();
this.secondsSofar = alreadyMinutes * 60;
this.interval = setInterval(() => this.secondsSofar++, 1000)
}
/** JSDocs too can help provide description beyond simple
* autocomplete, making it easier reduce, reuse and repurpose. */
willUnmount(){
clearInterval(this.interval)
}
}
const PaintDrying = ({ alreadyMinutes }) => {
/* Your IDE will know `alreadyMinutes` is supposed to be a number */
const { secondsSofar } = FunActivity.use(alreadyMinutes);
return (
<div>
I've been staring for like, { secondsSofar } seconds now,
and I'm starting to see what this is all about! 👀
</div>
)
}
There is nothing preventing you from use more than one controller in a component! Take advantage of this to create smaller, cooperating state, rather than big, monolithic state.
class PingController extends VC {
value = 1
}
class PongController extends VC {
value = 2
}
const ControllerAgnostic = () => {
const ping = PingController.use();
const pong = PongController.use();
return (
<div>
<div
onClick={() => { ping.value += pong.value }}>
Ping's value is ${ping.value}, click me to add in pong!
</div>
<div
onClick={() => { pong.value += pong.value }}>
Pong's value is ${pong.value}, click me to add in ping!
</div>
</div>
)
}
Controllers use a subscription model to decide when to render, and will only refresh for values which are actually used. They do this by watching property access on the first render, within a component they hook up to.
That said, while hooks can't actually read your function-component, destructuring is a good way to get consistent behavior. Where a property is not accessed on initial render render (inside a conditional or ternary), it could fail to update as expected.
Destructuring pulls out properties no matter what, and so prevents this problem. You'll also find also reads a lot better, and promotes better habits.
class FooBar {
foo = "bar"
bar = "foo"
}
const LazyComponent = () => {
const { set, foo } = use(FooBar);
return (
<h1
onClick={() => set.bar = "baz" }>
Foo is {foo} but click here to update bar!
</h1>
)
}
Here
LazyComponent
will not update whenbar
does change, because it only accessedfoo
here.
Rest assured. Changes made synchronously are batched as a single new render.
class ZeroStakesGame extends VC {
foo = "bar"
bar = "baz"
baz = "foo"
shuffle = () => {
this.foo = "???"
this.bar = "foo"
this.baz = "bar"
setTimeout(() => {
this.foo = "baz"
}, 1000)
}
}
const MusicalChairs = () => {
const { foo, bar, baz, shuffle } = ZeroStakesGame.use();
return (
<div>
<span>Foo is {foo}'s chair!</span>
<span>Bar is {bar}'s chair!</span>
<span>Baz is {baz}'s chair!</span>
<div onClick={shuffle}>🎶🥁🎶🎷🎶</div>
</div>
)
}
Even though we're ultimately making four updates,
use()
only needs to re-render twice. It does so once for everybody (being on the same event-loop), resets when finished, and again wakes forfoo
when it decides settle in.
Set behavior for certain properties on classes extending Controller
.
While standard practice is for use
to take all methods (and bind them), all properties (and watch them), there are special circumstances to be aware of.
Set behavior for certain properties on classes extending Controller
.
While standard practice is for use
to take all methods (and bind them), all properties (and watch them), there are special circumstances to be aware of.
Arrays
- if a property is an array, it will be forwarded to your components as a special
ReactiveArray
which can also trigger renders on mutate.
isProperty
- Properties matching
/is([A-Z]\w+)/
and whose value is a boolean will get a corresponding actiontoggle$1
.
_anything
- if a key starts with an underscore it will not trigger a refresh when overwritten (or carry any overhead to do so). No special conversions will happen. It's a shorthand for "private" keys which don't interact with the component.
Anything defined post-constructor
- important to notice that
use()
can only detect properties which exist (and are enumerable) at time of creation. If you create them after, they're also ignored.
set
/ get
- Not to be confused with setters / getters.
state.set
returns a circular reference tostate
- this is useful to access your state object while destructuring
export<T>(this: T): { [P in keyof T]: T[P] }
- takes a snapshot of live state you can pass along, without unintended side effects.
- this will only output the values which were enumerable in the source object.
add(key: string, value?: any): boolean
- adds a new tracked value to the live-state.
- this will return
true
if adding the key succeeded,false
if did not (because it exists). - setting value is optional, if absent,
key
simply begins watching.
Not really recommended after initializing, but could come in handy in a pinch.
didMount(): void
use()
will call this while internally runninguseEffect(fn, [])
for itself.
willUnmount(): void
use()
will call this before starting to clean up.
didHook(): void
- Called every render. A way to pipe data in from other hooks.
willHook(): void
- Called every render. However
this
references actual state only on first render, otherwise is a dummy. Useful for grabbing data without re-evaluating the properties you set in this callback every render. (e.g. things fromuseContext
)
License
MIT license.