@risingstack/react-easy-state
Version:
React state management with a minimal API. Made with ES6 Proxies.
397 lines (313 loc) • 12.3 kB
JavaScript
import { useState, memo, useMemo, useEffect, Component } from 'react';
import { observe, unobserve, isObservable, raw, observable } from '@nx-js/observer-util';
export { unobserve as clearEffect } from '@nx-js/observer-util';
import { unstable_batchedUpdates } from './react-platform';
// it is window in the DOM and global in NodeJS and React Native
const isDOM = typeof window !== 'undefined';
const isNative = typeof global !== 'undefined';
const globalObj = isDOM ? window : isNative ? global : undefined;
const hasHooks = typeof useState === 'function';
let isInsideFunctionComponent = false;
let isInsideClassComponentRender = false;
let isInsideFunctionComponentWithoutHooks = false;
const COMPONENT = Symbol('owner component');
function mapStateToStores(state) {
// find store properties and map them to their none observable raw value
// to do not trigger none static this.setState calls
// from the static getDerivedStateFromProps lifecycle method
const component = state[COMPONENT];
return Object.keys(component).map(key => component[key]).filter(isObservable).map(raw);
}
function view(Comp) {
const isStatelessComp = !(Comp.prototype && Comp.prototype.isReactComponent);
let ReactiveComp;
if (isStatelessComp && hasHooks) {
// use a hook based reactive wrapper when we can
ReactiveComp = props => {
// use a dummy setState to update the component
const [, setState] = useState(); // create a memoized reactive wrapper of the original component (render)
// at the very first run of the component function
const render = useMemo(() => observe(Comp, {
scheduler: () => setState({}),
lazy: true
}), // Adding the original Comp here is necessary to make React Hot Reload work
// it does not affect behavior otherwise
[Comp]); // cleanup the reactive connections after the very last render of the component
useEffect(() => {
return () => unobserve(render);
}, []); // the isInsideFunctionComponent flag is used to toggle `store` behavior
// based on where it was called from
isInsideFunctionComponent = true;
try {
// run the reactive render instead of the original one
return render(props);
} finally {
isInsideFunctionComponent = false;
}
};
} else {
const BaseComp = isStatelessComp ? Component : Comp; // a HOC which overwrites render, shouldComponentUpdate and componentWillUnmount
// it decides when to run the new reactive methods and when to proxy to the original methods
class ReactiveClassComp extends BaseComp {
constructor(props, context) {
super(props, context);
this.state = this.state || {};
this.state[COMPONENT] = this; // create a reactive render for the component
this.render = observe(this.render, {
scheduler: () => this.setState({}),
lazy: true
});
}
render() {
isInsideClassComponentRender = !isStatelessComp;
isInsideFunctionComponentWithoutHooks = isStatelessComp;
try {
return isStatelessComp ? Comp(this.props, this.context) : super.render();
} finally {
isInsideClassComponentRender = false;
isInsideFunctionComponentWithoutHooks = false;
}
} // react should trigger updates on prop changes, while easyState handles store changes
shouldComponentUpdate(nextProps, nextState) {
const {
props,
state
} = this; // respect the case when the user defines a shouldComponentUpdate
if (super.shouldComponentUpdate) {
return super.shouldComponentUpdate(nextProps, nextState);
} // return true if it is a reactive render or state changes
if (state !== nextState) {
return true;
} // the component should update if any of its props shallowly changed value
const keys = Object.keys(props);
const nextKeys = Object.keys(nextProps);
return nextKeys.length !== keys.length || nextKeys.some(key => props[key] !== nextProps[key]);
} // add a custom deriveStoresFromProps lifecyle method
static getDerivedStateFromProps(props, state) {
if (super.deriveStoresFromProps) {
// inject all local stores and let the user mutate them directly
const stores = mapStateToStores(state);
super.deriveStoresFromProps(props, ...stores);
} // respect user defined getDerivedStateFromProps
if (super.getDerivedStateFromProps) {
return super.getDerivedStateFromProps(props, state);
}
return null;
}
componentWillUnmount() {
// call user defined componentWillUnmount
if (super.componentWillUnmount) {
super.componentWillUnmount();
} // clean up memory used by Easy State
unobserve(this.render);
}
}
ReactiveComp = ReactiveClassComp;
}
ReactiveComp.displayName = Comp.displayName || Comp.name; // static props are inherited by class components,
// but have to be copied for function components
if (isStatelessComp) {
Object.keys(Comp).forEach(key => {
ReactiveComp[key] = Comp[key];
});
}
return isStatelessComp && hasHooks ? memo(ReactiveComp) : ReactiveComp;
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
function ownKeys(object, enumerableOnly) {
var keys = Object.keys(object);
if (Object.getOwnPropertySymbols) {
var symbols = Object.getOwnPropertySymbols(object);
if (enumerableOnly) symbols = symbols.filter(function (sym) {
return Object.getOwnPropertyDescriptor(object, sym).enumerable;
});
keys.push.apply(keys, symbols);
}
return keys;
}
function _objectSpread2(target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i] != null ? arguments[i] : {};
if (i % 2) {
ownKeys(Object(source), true).forEach(function (key) {
_defineProperty(target, key, source[key]);
});
} else if (Object.getOwnPropertyDescriptors) {
Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
} else {
ownKeys(Object(source)).forEach(function (key) {
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
});
}
}
return target;
}
const taskQueue = new Set();
const scheduler = {
isOn: false,
add(task) {
if (scheduler.isOn) {
taskQueue.add(task);
} else {
task();
}
},
flush() {
taskQueue.forEach(task => task());
taskQueue.clear();
},
on() {
scheduler.isOn = true;
},
off() {
scheduler.isOn = false;
}
};
// until the function is finished running
// react renders are batched by unstable_batchedUpdates
// autoEffects and other custom reactions are batched by our scheduler
function batch(fn, ctx, args) {
// do not apply scheduler logic if it is already applied from a parent function
// it would flush in the middle of the parent's batch
if (scheduler.isOn) {
return unstable_batchedUpdates(() => fn.apply(ctx, args));
}
try {
scheduler.on();
return unstable_batchedUpdates(() => fn.apply(ctx, args));
} finally {
scheduler.flush();
scheduler.off();
}
} // this creates and returns a batched version of the passed function
// the cache is necessary to always map the same thing to the same function
// which makes sure that addEventListener/removeEventListener pairs don't break
const cache = new WeakMap();
function batchFn(fn) {
if (typeof fn !== 'function') {
return fn;
}
let batched = cache.get(fn);
if (!batched) {
batched = new Proxy(fn, {
apply(target, thisArg, args) {
return batch(target, thisArg, args);
}
});
cache.set(fn, batched);
}
return batched;
}
function batchMethodCallbacks(obj, method) {
const descriptor = Object.getOwnPropertyDescriptor(obj, method);
if (descriptor && descriptor.writable && typeof descriptor.value === 'function') {
obj[method] = new Proxy(descriptor.value, {
apply(target, ctx, args) {
return Reflect.apply(target, ctx, args.map(batchFn));
}
});
}
} // batched obj.addEventListener(cb) like callbacks
function batchMethodsCallbacks(obj, methods) {
methods.forEach(method => batchMethodCallbacks(obj, method));
}
function batchMethod(obj, method) {
const descriptor = Object.getOwnPropertyDescriptor(obj, method);
if (!descriptor) {
return;
}
const {
value,
writable,
set,
configurable
} = descriptor;
if (configurable && typeof set === 'function') {
Object.defineProperty(obj, method, _objectSpread2({}, descriptor, {
set: batchFn(set)
}));
} else if (writable && typeof value === 'function') {
obj[method] = batchFn(value);
}
} // batches obj.onevent = fn like calls and store methods
function batchMethods(obj, methods) {
methods = methods || Object.getOwnPropertyNames(obj);
methods.forEach(method => batchMethod(obj, method));
return obj;
} // do a sync batching for the most common task sources
// this should be removed when React's own batching is improved in the future
// batch timer functions
batchMethodsCallbacks(globalObj, ['setTimeout', 'setInterval', 'requestAnimationFrame', 'requestIdleCallback']);
if (globalObj.Promise) {
batchMethodsCallbacks(Promise.prototype, ['then', 'catch']);
} // Event listener batching causes an input caret jumping bug:
// https://github.com/RisingStack/react-easy-state/issues/92.
// This part has to be commented out to prevent that bug.
// React batches setStates in its event listeners anyways
// so this commenting this part out is not a huge issue.
// batch addEventListener calls
/* if (globalObj.EventTarget) {
batchMethodsCallbacks(EventTarget.prototype, [
'addEventListener',
'removeEventListener',
]);
} */
// this batches websocket event handlers
if (globalObj.WebSocket) {
batchMethods(WebSocket.prototype, ['onopen', 'onmessage', 'onerror', 'onclose']);
} // HTTP event handlers are usually wrapped by Promises, which is covered above
function createStore(obj) {
return batchMethods(observable(typeof obj === 'function' ? obj() : obj));
}
function store(obj) {
// do not create new versions of the store on every render
// if it is a local store in a function component
// create a memoized store at the first call instead
if (isInsideFunctionComponent) {
// useMemo is not a semantic guarantee
// In the future, React may choose to “forget” some previously memoized values and recalculate them on next render
// see this docs for more explanation: https://reactjs.org/docs/hooks-reference.html#usememo
return useMemo(() => createStore(obj), []);
}
if (isInsideFunctionComponentWithoutHooks) {
throw new Error('You cannot use state inside a function component with a pre-hooks version of React. Please update your React version to at least v16.8.0 to use this feature.');
}
if (isInsideClassComponentRender) {
throw new Error('You cannot use state inside a render of a class component. Please create your store outside of the render function.');
}
return createStore(obj);
}
function autoEffect(fn, deps = []) {
if (isInsideFunctionComponent) {
return useEffect(() => {
const observer = observe(fn, {
scheduler: () => scheduler.add(observer)
});
return () => unobserve(observer);
}, deps);
}
if (isInsideFunctionComponentWithoutHooks) {
throw new Error('You cannot use autoEffect inside a function component with a pre-hooks version of React. Please update your React version to at least v16.8.0 to use this feature.');
}
if (isInsideClassComponentRender) {
throw new Error('You cannot use autoEffect inside a render of a class component. Please use it in the constructor or lifecycle methods instead.');
}
const observer = observe(fn, {
scheduler: () => scheduler.add(observer)
});
return observer;
}
export { autoEffect, batch, store, view };
//# sourceMappingURL=es.es6.js.map