- Type-safe: Full TypeScript support to provides full type inference for:
- State structure
- Selector return types
- Action parameters
- Path-based updates
- State history
- Immutable Updates: Simple mutable-style syntax with immutable results
- Direct path updates: Modify nested properties using dot-bracket notation or
Array<string | number>instead of spreading multiple levels - Referential stability: Only modified portions of state create new references, preventing unnecessary re-renders
- Flexible API: Support for both local or global state
- Custom selectors: Prevent component re-renders by selecting only relevant state fragments
- High Performance: Built on Mutative's efficient immutable updates,
con-estadois particularly useful for applications with:- Complex nested state structures
- Performance-sensitive state operations
- Frequent partial state updates
- Teams wanting to reduce state management boilerplate
npm i con-estado
yarn add con-estado
deno add jsr:@rafde/con-estado
For applications needing global state management, createConStore provides a solution for creating actions and optimized updates:
Key advantages:
- Global state accessible across components
- Optimized subscriptions through selector-based consumption
When using createConStore, the useConSelector hook is provided for optimized component updates:
const useConSelector = createConStore(initialState);
function UserProfile() {
// Only re-renders when selected data changes
const userData = useConSelector( ( { state } ) => ({
name: state.user.name,
avatar: state.user.avatar
}));
return <div>
<img src={userData.avatar} alt={userData.name} />
<h2>{userData.name}</h2>
</div>;
}
- Select minimal required data
- Memoize complex computations
- Return stable references
- Use TypeScript for type safety
When you need to manage state within a component with the power of createConStore, useCon has you covered:
con-estado supports flexible path notation for state updates:
// from useCon
const theme = useSelector( 'state.user.preferences.theme' );
// from createConStore
const globalTheme = useConSelector( 'state.user.preferences.theme' );
const set = useSelector( 'set' );
// Dot-bracket notation
set( 'state.user.preferences.theme', 'dark' );
// Array notation
set( [ 'state', 'user', 'preferences', 'theme' ], 'dark');
// Array indices
set( 'state.todos[0].done', true );
set( 'state.todos[-1].text', 'Last item' ); // Negative indices supported
const merge = useConSelector( 'merge' );
// Array operations
merge( 'state.todos', [ { done: true, } ] ) ; // Merge first element in array
set( 'state.todos', [] ); // Clear array
Custom Selector is a function that optimize renders by selecting only needed state:
function UserPreferences() {
const preferences = useCon( initialState, props => ( {
theme: props.state.user.preferences.theme,
updateTheme( event: ChangeEvent<HTMLSelectElement> ) {
props.set(
event.target.name as ConHistoryStateKeys<typeof initialState>,
event.target.value,
);
},
} ), );
return <select
value={preferences.theme}
name="state.user.preferences.theme"
onChange={preferences.updateTheme}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>;
}
Define reusable actions for complex state updates:
function PostList() {
const [ state, { acts, } ] = useCon(
{ posts: [ { id: 1, text: 'post', } ] },
{
acts: ( { wrap, commit, set, } ) => ({
addPost( post: Post, ) {
commit( 'posts', props => {
props.stateProp.push( post );
});
},
updatePost: wrap(
'posts',
( { stateProp }, id: number, updates: Partial<Post>, ) => {
const post = stateProp.find( p => p.id === id );
if (post) Object.assign( post, updates );
}
),
async fetchPosts() {
const posts = await api.getPosts();
set( 'state.posts', posts );
},
},
})
);
return <div>
{state.posts.map( post => (
<PostItem
key={post.id}
post={post}
onUpdate={updates => acts.updatePost(post.id, updates)}
/>
) )}
<button onClick={acts.fetchPosts}>
Refresh Posts
</button>
</div>;
}
Track and access previous state values:
- state: Current immutable state object.
- prev: The previous immutable
stateobject beforestatewas updated. - initial: Immutable initial state it started as. It can be updated through
historyDraftfor re-sync purposes, such as merging server data intoinitialwhilestatekeeps latest client side data. - prevInitial: The previous immutable
initialobject beforeinitialwas updated. - changes: Immutable object that keeps track of deeply nested difference between the
stateandinitialobject.
function StateHistory() {
const [ state, { get, reset, }, ] = useCon( initialState, );
const history = get(); // Get full state history
const prev = history.prev;
return <div>
<pre>{JSON.stringify(prev, null, 2)}</pre>
<button onClick={reset}>Reset State</button>
</div>;
}
In cases where you need to consecutively set, merge, or reset data,
you probably don't want to trigger consecutive re-renders. In this case, you can batch these updates by calling them in
commit or wrap
// state.set.some.arr = [ { data: 0 }, ]
commit( () => {
// state.set.some.arr = [ { data: 0 }, { data: 1 }, ]
merge( 'state.set.some.arr', [ , { data: 1 }, ] );
// state.set.some.arr = []
set( 'state.set.some.arr', [] );
// state.set.some.arr = [ { data: 0 }, ]
reset();
});
This provides the convenience of using merge, set, or reset without having to worry about multiple re-renders.
An complex to-do app example of how con-estado can be used.
createConStore and useCon take the same parameters.
// works with createConStore
useCon( {} );
useCon( () => ({}) );
useCon( [] );
useCon( () => ([]) );
Used to initialize the state value. non-null Object, Array,
or a function that returns a non-null Object or Array
Configuration options for createConStore and useCon.
useCon( initial, options );
createConStore( initialState, options );
Optional factory function for creating a Record of action handlers and state transformations. The action handlers have access to a subset of the controls object.
Return type: Record<string | number, (...args: unknown[]) => unknown>
useCon(
initial,
{
acts: ( {
commit,
get,
merge,
reset,
set,
wrap,
} ) => ( {
// your actions with async support
yourAction( props, ) {
// your code
}
} ),
}
);
Function to modify state before it's committed to history. Enables validation, normalization, or transformation of state updates.
- historyDraft: A Mutative draft of
stateandinitialthat can be modified for additional changes. - history: Immutable State History. Does not have latest changes.
- type: The operation type (
'set' | 'reset' | 'merge' | 'commit' | 'wrap') that triggered changes. - patches: A partial state object that contains the latest deeply nested changes made to
stateand/orinitial. Useful for when you want to include additional changes based on whatpatchescontains.
Return type: void
useCon( initialState, {
beforeChange: ({
historyDraft, // Mutable draft of `state` and `initial`
history, // Current immutable history
type, // Operation type: 'set' | 'reset' | 'merge' | 'commit' | 'wrap'
patches, // Latest changes made to `state` or `initial`
}) => {
// Validate changes
if (historyDraft.state.count < 0) {
historyDraft.state.count = 0;
}
// Add additional changes
if (patches.user?.name) {
historyDraft.state.lastUpdated = Date.now();
}
}
});
Post-change async callback function executed after state changes are applied.
Provides access to the updated State History and the patches that were made.
Return type: void
useCon(
initial,
{
afterChange(
{ state, initial, prev, prevInitial, changes, },
{ state, initial } // patches: what deeply nested specific changes where made
) {
// your code with async support
}
}
);
Configuration for mutative options.
useCon( initialState, {
mutOptions: {
// Mutative options (except enablePatches)
strict: true,
// ... other Mutative options
}
});
{enablePatches: true} not supported.
Custom selector callback that lets you shape what is returned from useCon and/or createConStore.
useCon Example:
useCon(
initialState,
options,
( {
acts,
commit,
get,
merge,
reset,
set,
wrap,
state,
subscribe,
useSelector, // only available in `useCon`
}, ) => unknown // `unknown` represents the return type of your choice
);
// Example without options
useCon( initialState, selector, );
createConStore(
initialState,
options,
( {
acts,
commit,
get,
merge,
reset,
set,
wrap,
state,
subscribe,
}, ) => unknown // unknown represents the return type of your choice
);
// Example without options
createConStore( initialState, selector, );
TIP: When selectors return a function or object with a function, those functions will not trigger re-render when changed. This is a precaution to prevent unnecessary re-renders since creating functions create a new reference.
Examples:
// Won't re-render
const setCount = useCon(
initialState,
controls => controls.state.count < 10
? controls.wrap( 'count' )
: () => {}
);
// Won't re-render, but it will do something.
const setCount = useCon( initialState, controls => (value) => {
controls.state.count < 10
? controls.set( 'state.count', value )
: undefined
});
// This will re-render when `controls.state.count` has updated.
const setCount = useCon( initialState, controls => ({
count: controls.state.count,
setCount: controls.state.count < 10
? controls.wrap( 'count' )
: () => {}
}));
const useConSelector = createConStore(
initialState,
( { set, }, ) => set,
);
// this will never trigger re-renders because the selector returned a function.
const set = useConSelector();
// this will re-render when `state` changes.
const [
set,
state,
] = useConSelector( ( { set, state, }, ) => [ set, state, ] as const );
Local state manager for a React Component
const [ state, controls, ] = useCon( initialState, options, selector, );
useCon has access to additional control property from selector named useSelector.
A function that works like what createConStore returns.
- By default, returns
[state, controls]when no selector is provided. If aselectoris provided, it returns the result of theselector. - This allows you to use local state as a local store that can be passed down to other components, where each component can provide a custom
selector.
const useSelector = useCon( initialState, controls => controls.useSelector );
const state = useSelector(controls => controls.state);
const set = useSelector(controls => controls.set);
TIP: If your selector return value is/has a function, function will not be seen as a change to
trigger re-render. This is a precaution to prevent unnecessary re-renders since all dynamic functions create a new reference.
If you need to conditional return a function, it's better if you make a function that can handle your condition.
example
// Won't re-render
const setCount = useCon(
initialState,
controls => controls.state.count < 10
? controls.wrap( 'count' )
: () => {}
);
// Won't re-render, but it will do something.
const setCount = useCon( initialState, controls => (value) => {
controls.state.count < 10 ? controls.set( 'state.count', value ) : undefined
});
// This will re-render when `controls.state.count` value is updated
const setCount = useCon( initialState, controls => ({
count: controls.state.count,
setCount: controls.state.count < 10 ? controls.wrap( 'count' ) : () => {}
}));
You can also access the controls directly from useSelector using a string path.
If a string path starts with state, initial, prev, prevInitial or changes,
it returns the value of the property from the State History.
const name = useSelector( 'state.user.name' );
If a string path to acts is provided, it returns the action function.
const yourAction = useSelector( 'acts.yourAction' );
Other string paths to get, commit, merge, reset, set, subscribe, wrap will return corresponding function.
Global store state manager.
const useConSelector = createConStore( initialState, options, selector, );
Called useConSelector for reference. You have a choice in naming.
By default, returns [ state, controls, ] when no selector is provided.
const [ state, controls, ] = useConSelector();
useConSelector has static props
// static props
const {
acts,
commit,
get,
merge,
reset,
set,
subscribe,
wrap,
} = useConSelector
If a selector is provided from createConStore or useConSelector, it returns the result of the selector.
const yourSelection = useConSelector(
( {
acts,
commit,
get,
merge,
reset,
set,
state,
subscribe,
wrap,
}, ) => unknown
);
You can also access the controls directly from useConSelector using a string path.
If a string path starts with state, initial, prev, prevInitial or changes,
it returns the value of the property from the State History.
const name = useConSelector( 'state.user.name' );
If a string path to acts is provided, it returns the action function.
const yourAction = useConSelector( 'acts.yourAction' );
Other string paths to get, commit, merge, reset, set, subscribe, wrap will return corresponding function.
The following functions
have access to the following controls:
The get() function returns a complete immutable State History object:
const [
state,
{ get, }
] = useCon( { count: 0, }, );
const {
get,
} = useConSelector( ( { get, } ) => ( { get, } ), ) ;
const history = get();
// Available properties:
history.state; // Current state
history.prev; // Previous state
history.initial; // Initial state
history.prevInitial; // Previous initial state
history.changes; // Tracked changes between state and initial
// Access nested properties directly
const specificChange = get( 'changes.user.name' );
const specificChange = get( [ 'changes', 'user', '.name' ] );
The current immutable state value. Initialized from options.initialState.
Same value as get( 'state' ). Provided for convenience and to trigger re-render on default selector update.
const [
state,
] = useCon( { count: 0, }, );
const {
state,
} = useConSelector( ( { state, } ) => ( { state, }, ) );
set provides ways to replace state and/or initial values simultaneously.
// state = { count: 1, items: ['old'] }
// initial = { count: 0, items: [] }
set( {
state: { count: 5, items: ['new'] },
initial: { count: 5, items: ['new'] }
} );
// state = { count: 5, items: ['new'] }
// initial = { count: 5, items: ['new'] }
set( {
state: { count: 10, items: ['new new'] },
} );
// state = { count: 10, items: ['new new'] }
// initial = { count: 5, items: ['new'] }
set( {
initial: { count: 20, items: ['new new new'] },
} );
// state = { count: 10, items: ['new new'] }
// initial = { count: 20, items: ['new new new'] }
Replace specific values at specific paths in state or initial:
// state = { user: { name: 'John', age: 25 }, items: [ 'one', 'two' ] }
// initial = { user: { name: 'John', age: 20 } }
// String path
set( 'state.user.age', 30 );
// state.user.age === 30
// initial unchanged
set( 'initial.user.age', 21 );
// initial.user.age === 21
// state unchanged
// Set array
set( 'state.items', [ 'three', 'four' ] );
// state.items === [ 'three', 'four' ]
// Set specific index
set( 'state.items[0]', 'updated' );
// state.items === [ 'updated', 'four' ]
// Array path
set( [ 'state', 'user', 'name' ], 'Jane' );
// state.user.name === 'Jane'
Negative indices are allowed, but they can't be out of bounds. E.g., [ 'initial', 'posts', -1 ] or initial.posts[-1]
is valid if 'posts' has at least one element.
// initial = { posts: [
// undefined,
// { title: 'Second post', content: 'Second post content', },
// ], }
set( 'initial.posts[-1]', { title: 'Updated Second Title', } );
// initial = { posts: [
// undefined,
// { title: 'Updated Second Title', },
// ], };
set( [ 'initial', 'posts', -2 ], { title: 'Updated First Content' }, );
// initial = { posts: [
// { title: 'Updated First Content', },
// { title: 'Updated Second Title', },
// ], };
set( 'initial.posts[-3]', { title: 'Third Title', }, ); // throws error
Error Cases
Throws errors in these situations:
- Trying to access non-object/array properties with dot-bracket notation
- Out of bounds negative indices
// initial = {
// count: 1,
// posts: ['post1', 'post2']
// };
// Invalid paths
set( 'initial.count.path.invalid', 42 ); // Error: `count` is not an object.
// Out of bounds
set( 'initial.posts[-999]', 'value' ); // Error: Index out of bounds. Array size is 2.
Keys containing dots ., or opening bracket [ must be escaped with backslashes.
Does not apply to array path keys.
// initial = {
// path: {
// 'user.name[s]': 'Name',
// },
// };
set( 'initial.path.user\\.name\\[s]', 'New Name', );
// set( [ 'initial', 'path', 'user.name[s]' ], 'New Name', );
Automatically creates intermediate objects/arrays:
// state = {}
// initial = {}
set( 'state.deeply.nested.value', 42 );
// state = {
// deeply: {
// nested: {
// value: 42
// }
// }
// }
// Arrays are created for numeric paths
set( 'initial.items[0].name', 'First', );
// initial = {
// items: [ { name: 'First' } ]
// }
Error Cases
Throws errors in these situations:
- Trying to access non-object/array properties with dot-bracket notation
- Out of bounds negative indices
// initial = {
// count: 1,
// posts: ['post1', 'post2']
// };
// Invalid paths
set( 'initial.count.path.invalid', 42, ); // Error: `count` is not an object.
// Out of bounds
set( 'initial.posts[-999]', 'value', ); // Error: Index out of bounds. Array size is 2.
The commit method provides atomic updates to both state and initial values.
It supports the following usage patterns:
Update multiple values at the root level:
commit( ( { state, initial } ) => {
state.count = 5;
state.user.name = 'John';
initial.count = 0;
});
Contains the following parameters:
- props: State History properties.
- state: Mutable
stateobject that can be modified in the callback. - initial: Mutable
initialobject that can be modified in the callback. - prev: Immutable previous
stateobject (undefinedon first update). - prevInitial: Immutable previous
initialobject (undefinedon first update). - changes: Immutable changes made to state (
undefinedon first update).
- state: Mutable
commit( ( {
state, // Mutable current state
initial, // Mutable initial state
prev, // Immutable previous state
prevInitial,// Immutable previous initial state
changes, // Immutable changes made to state
} ) => {
// Your update logic
});
Update state and/or initial at a specific path using dot-bracket notation:
// {
// state: {
// user: {
// profile: { name: 'John' },
// settings: { theme: 'light' }
// },
// posts: ['post1', 'post2']
// },
// initial: {
// user: {
// profile: { name: '' },
// settings: { theme: 'light' }
// },
// posts: ['post1', ]
// }
// };
// String path
commit( 'user.profile.name', (props) => {
props.stateProp = 'Jane';
});
// state.user.profile.name === 'Jane'
// Array path
commit( [ 'user', 'settings', 'theme' ], ( props ) => {
props.initialProp = 'light';
});
// Array indices
commit( 'posts[0]', ( props ) => {
props.stateProp = 'updated post';
});
// commit( [ 'posts', 0 ], ( props ) => { props.stateProp = 'updated post'; });
// Clear array
commit( 'posts', ( props ) => {
props.stateProp = [];
});
commit( [ 'posts' ], ( props ) => { props.stateProp = []; });
// state.posts = []
Negative indices are allowed, but they can't be out of bounds. E.g., ['posts', -1] or posts[-1]
is valid if 'posts' has at least one element.
// state = { posts: [
// undefined,
// { title: 'Second post', content: 'Second post content', },
// ], }
commit( 'posts[-1]', ( props ) => {
props.stateProp = 'Updated Second Title';
});
// state = { posts: [
// undefined,
// { title: 'Updated Second Title', content: 'Second post content', },
// ], };
commit( [ 'posts', -2, 'title' ], ( props ) => {
props.stateProp = 'Updated First Content';
} );
// state = { posts: [
// { title: 'Updated First Content', },
// { title: 'Updated Second Title', content: 'Second post content', },
// ], };
commit( 'posts[-3]', ( props ) => {
props.stateProp = 'Third Title'; // throws error
}, );
Error Cases
Throws errors in these situations:
- Trying to access non-object/array properties with dot-bracket notation
- Out of bounds negative indices
// state = {
// count: 1,
// posts: ['post1', 'post2']
// };
// Invalid paths
commit( 'count.path.invalid', ( props ) => {
props.stateProp = 42; // Error: `count` is not an object.
});
// Out of bounds
commit( 'posts[-999]', ( props ) => {
props.stateProp = 'value'; // Error: Index out of bounds. Array size is 2.
});
Keys containing dots ., or opening bracket [ must be escaped with backslashes.
Does not apply to array path keys.
// state = {
// path: {
// 'user.name[s]': 'Name',
// },
// };
commit( 'path.user\\.name\\[s]', ( props ) => {
props.stateProp = 'New Name';
}, );
// commit( [ 'path', 'user.name[s]' ], ( props ) => {
// props.stateProp = 'New Name';
//}, ); );
// state.path.user.name[s] === 'New Name'
When setting a value at a non-existing path, intermediate objects or arrays are created automatically:
commit( 'deeply.nested.value', ( props ) => {
props.stateProp = 42;
});
// state = {
// deeply: {
// nested: {
// value: 42
// }
// }
// };
// Arrays are created when using numeric paths
commit( 'items[0].name', ( props ) => {
props.stateProp = 'First';
});
// state = {
// deeply: {
// nested: {
// value: 42
// }
// },
// items: [ { name: 'First' } ]
// };
Error Cases
Throws errors in these situations:
- Trying to access non-object/array properties with dot-bracket notation
- Out of bounds negative indices
// state = {
// count: 1,
// posts: [ 'post1', 'post2' ]
// };
// Invalid paths
commit( 'count.path.invalid', ( props ) => {
props.stateProp = 42; // Error: `count` is not an object.
});
// Out of bounds
commit( 'posts[-999]', ( props ) => {
props.stateProp = 'value'; // Error: Index out of bounds. Array size is 2.
});
Same as commit Callback Parameters plus:
- stateProp: Mutable
statevalue relative to path. - initialProp: Mutable
initialvalue relative to path. - prevProp: Immutable
prevvalue relative to path. Can beundefined. - prevInitialProp: Immutable
prevInitialvalue relative to path. Can beundefined. - changesProp: Immutable
changesvalue relative to path. Can beundefined.
commit(
'my.data',
(
{
// same as commit( callback )
state, prev, initial, prevInitial, changes,
// Path-based properties
stateProp, // Mutable `state` value relative to path
initialProp, // Mutable `initial` value relative to path
prevProp, // Immutable `prev` state value relative to path, (`undefined` on first update).
prevInitialProp, // Immutable `prevInitial` value relative to path, (`undefined` on first update).
changesProp, // Immutable `changes` value made to state value relative to path, (`undefined` on first update).
},
) => {
// your code
}, );
Merge objects/arrays at a specific path.
// initial.user = {
// profile: { firstName: 'John', },
// preferences: { theme: 'light', },
// };
merge( {
initial: {
user: {
profile: { lastName: 'Doe', },
preferences: { theme: 'dark', },
},
},
} );
// initial.user = {
// profile: { firstName: 'John', lastName: 'Doe', },
// preferences: { theme: 'dark', },
// };
merge updates state at a specific string path, e.g. 'user.profile',
in the history state using a dot-bracket-notation for path or array of strings or numbers (for arrays).
// initial = {
// user: {
// profile: { name: 'John' },
// settings: { notifications: { email: true } }
// }
// };
// String path
merge( 'initial.user.profile', { age: 30 } );
// initial.user.profile === { name: 'John', age: 30 }
// Array path
merge( [ 'initial', 'user', 'settings' ], { theme: 'dark' } );
// initial.user.settings === { notifications: { email: true }, theme: 'dark' }
Negative indices are allowed, but they can't be out of bounds. E.g., ['posts', -1] or posts[-1]
is valid if 'posts' has at least one element.
// initial = { posts: [
// undefined,
// { title: 'Second post', content: 'Second post content', },
// ], }
merge( 'initial.posts[-1]', { title: 'Updated Second Title', } );
// initial = { posts: [
// undefined,
// { title: 'Updated Second Title', content: 'Second post content', },
// ], };
merge( [ 'initial', 'posts', -2 ], { title: 'Updated First Content' }, );
// initial = { posts: [
// { title: 'Updated First Content', },
// { title: 'Updated Second Title', content: 'Second post content', },
// ], };
merge( 'initial.posts[-3]', { title: 'Third Title', }, ); // throws error
Error Cases
Throws errors in these situations:
- Type mismatches
- Trying to access non-object/array properties with dot-bracket notation
- Out of bounds negative indices
// initial = {
// count: 1,
// posts: ['post1', 'post2']
// };
// Invalid paths
merge( 'initial.count.path.invalid', 42 ); // Error: `count` is not an object.
// Invalid paths
merge( 'initial.posts', { post: 'new post' } ); // Error: cannot merge object into array
// Out of bounds
merge( 'initial.posts[-999]', 'value' ); // Error: Index out of bounds. Array size is 2.
Keys containing dots ., or opening bracket [ must be escaped with backslashes.
Does not apply to array path keys.
// initial = {
// path: {
// 'user.name[s]': 'Name',
// },
// };
merge( 'initial.path.user\\.name\\[s]', 'New Name', );
// merge( [ 'initial', 'path', 'user.name[s]' ], 'New Name', );
Automatically creates intermediate objects/arrays:
// initial = {};
merge( 'initial.nested.data', { value: 42 });
// initial = {
// nested: {
// data: { value: 42 }
// }
// };
merge( 'initial.items[1]', { name: 'Second' });
// initial = {
// items: [
// undefined,
// { name: 'Second' }
// ]
// };
- Non-plain Objects:
// Non-plain objects replace instead of merge
merge( 'initial.date', new Date() ); // Replaces entire value
merge( 'initial.regex', /pattern/ ); // Replaces entire value
merge( 'initial.existing.object', {} ); // Does nothing
- Array Operations:
// initial = { items: [1, 2, 3] };
// Update specific array elements using sparse arrays
merge( 'initial.items', [
undefined, // Skip first element
22 // Update second element
]);
// initial = { items: [1, 22, 3] };
// Using negative indices
merge( 'initial.items[-1]', -11 );
// Updates last element
// initial = { items: [ 1, 22, -11 ] };
// To clear an array, use set instead
merge( 'initial.items', [] ); // Does nothing
set( 'initial.items', [] ); // Correct way to clear
wrap creates reusable state updater functions that can accept additional parameters.
Can be called within commit or wrap callbacks.
It supports three different usage patterns:
Create reusable state updater functions that can accept additional parameters.
// { state: { count: 1 }, initial: { count: 0 } };
const yourFullStateUpdater = wrap( async ( { state, initial }, count: number ) => {
state.count += count;
initial.lastUpdated = 'yesterday';
state.lastUpdated = 'today';
// supports async
return await Promise.resolve( state );
});
const state = await yourFullStateUpdater( 1 );
// { state: { count: 2, lastUpdated: 'today' }, initial: { count: 0, lastUpdated: 'yesterday' } };
Contains the following parameters:
- props: State History properties.
- state: Mutable
stateobject that can be modified in the callback. - initial: Mutable
initialobject that can be modified in the callback. - prev: Immutable previous
stateobject. - prevInitial: Immutable previous
initialobject. - changes: Immutable changes object.
- state: Mutable
- ...args: Optional additional arguments passed to the wrap
const yourUpdater = wrap(
(
{
state, // Mutable state
initial, // Mutable initial state
prev, // Immutable previous state
prevInitial, // Immutable previous initial state
changes, // Immutable changes
},
...args
) => {
// your code
},
);
Update state and/or initial at a specific path using dot-bracket notation:
// {
// state: {
// user: {
// profile: { name: 'John' },
// settings: { theme: 'light' }
// },
// posts: ['post1', 'post2']
// },
// initial: {
// user: {
// profile: { name: '' },
// settings: { theme: 'light' }
// },
// posts: [ 'post1', ]
// }
// };
// String path
const setName = wrap( 'user.profile.name', ( props, name ) => {
props.initialProp = props.stateProp;
props.stateProp = name;
});
setName( 'Jane' );
// initial.user.profile.name === 'John'
// state.user.profile.name === 'Jane'
// Array path
const setTheme = wrap( [ 'user', 'settings', 'theme' ], ( props, theme ) => {
props.initialProp = props.stateProp;
props.stateProp = theme;
});
setTheme( 'dark' );
// initial.user.settings.theme === 'light'
// state.user.settings.theme === 'dark'
// Array indices
const updatePost = wrap( 'posts[0]', ( props, post ) => {
props.stateProp = post;
});
// wrap( [ 'posts', 0 ], ( props, post ) => { props.stateProp = post; });
updatePost( 'updated post' );
// state.posts[0] = 'updated post'
// Clear array
const clearPosts = wrap( 'posts', ( props ) => {
props.stateProp = [];
});
// const clearPosts = wrap( [ 'posts' ], ( props ) => { props.stateProp = []; });
clearPosts();
// state.posts = []
Negative indices are allowed, but they can't be out of bounds. E.g., ['posts', -1] or posts[-1]
is valid if 'posts' has at least one element.
// state = { posts: [
// undefined,
// { title: 'Second post', content: 'Second post content', },
// ], }
const setLastPost = wrap( 'posts[-1]', (props, post) => {
props.stateProp = post;
});
setLastPost( { title: 'Updated Second Title', });
// state = { posts: [
// undefined,
// { title: 'Updated Second Title', content: 'Second post content', },
// ], };
const setPenultimatePost = wrap( [ 'posts', -2 ], ( props, post ) => {
props.stateProp = post;
} );
setPenultimatePost( { title: 'Updated First Content' }, );
// state = { posts: [
// { title: 'Updated First Content', },
// { title: 'Updated Second Title', content: 'Second post content', },
// ], };
const setPenPenultimatePost = wrap( 'posts[-3]', ( props, post ) => {
props.stateProp = post;
}, );
setPenPenultimatePost( { title: 'Third Title', }, ); // throws error
Error Cases
Throws errors in these situations:
- Trying to access non-object/array properties with dot-bracket notation
- Out of bounds negative indices
// state = {
// count: 1,
// posts: ['post1', 'post2']
// };
// Invalid paths
const yourUpdater = wrap( 'count.path.invalid', ( props, value ) => {
props.stateProp = value;
});
yourUpdater( 42 ); // Error: `count` is not an object.
// Out of bounds
const outOfBoundsUpdater = wrap( 'posts[-999]', ( props, value ) => {
props.stateProp = value;
});
outOfBoundsUpdater( 'value' ); // Error: Index out of bounds. Array size is 2.
Keys containing dots ., or opening bracket [ must be escaped with backslashes.
Does not apply to array path keys.
// state = {
// path: {
// 'user.name[s]': 'Name',
// },
// };
const yourUpdater = wrap( 'path.user\\.name\\[s]', ( props, value ) => {
props.stateProp = value;
}, );
// wrap( [ 'path', 'user.name[s]' ], ( props, value ) => {
// props.stateProp = value;
//}, ); );
yourUpdater( 'New Name' );
// state.path.user.name[s] === 'New Name'
When setting a value at a non-existing path, intermediate objects or arrays are created automatically:
// state = {
// count: 1,
//};
const yourUpdater = wrap( 'deeply.nested.value', ( props, value ) => {
props.stateProp = value;
});
yourUpdater( 42 );
// state = {
// deeply: {
// nested: {
// value: 42
// }
// }
// };
// Arrays are created when using numeric paths
const yourItemUpdater = wrap( 'items[0].name', ( props, name ) => {
props.stateProp = name;
});
yourItemUpdater( 'First' );
// state = {
// items: [ { name: 'First' } ]
// };
Error Cases
Throws errors in these situations:
- Trying to access non-object/array properties with dot-bracket notation
- Out of bounds negative indices
// state = {
// count: 1,
// posts: [ 'post1', 'post2' ]
// };
// Invalid paths
const yourUpdater = wrap( 'count.path.invalid', ( props, value ) => {
props.stateProp = value;
});
yourUpdater( 42 ); // Error: `count` is not an object.
// Out of bounds
const outOfBoundsUpdater = wrap( 'posts[-999]', ( props, value ) => {
props.stateProp = value;
});
outOfBoundsUpdater( 'value' ); // Error: Index out of bounds. Array size is 2.
Same as wrap Callback Parameters plus:
- stateProp: Mutable
statevalue relative to path. - initialProp: Mutable
initialvalue relative to path. - prevProp: Immutable
prevvalue relative to path. Can beundefined. - prevInitialProp: Immutable
prevInitialvalue relative to path. Can beundefined. - changesProp: Immutable
changesvalue relative to path. Can beundefined.
const yourUpdater = wrap(
'my.data',
(
{
// same as wrap( callback )
state, prev, initial, prevInitial, changes,
// Path-based properties
stateProp, // Mutable `state` value relative to path
initialProp, // Mutable `initial` value relative to path
prevProp, // Immutable `prev` state value relative to path
prevInitialProp, // Immutable `prevInitial` value relative to path
changesProp, // Immutable `changes` value made to state value relative to path
},
...args
) => {
// your code
}, );
Wrapped functions can return Promise-like or non-Promise values:
// state = { items: [ 'a', 'b', 'c' ] }
const removeItem = wrap(
( { state }, index: number ) => {
const removed = state.items[index];
state.items.splice( index, 1 );
return removed;
}
);
// Usage
const removed = removeItem( 1 ); // returns 'b'
// state.items === [ 'a', 'c' ]
Returning Mutative draft objects will be converted to immutable objects.
Resets state to initial.
Can be called within commit or wrap callbacks.
const [
state,
{ reset, },
] = useCon( { count: 0, }, );
const {
reset,
} = useConSelector( ( { reset, } ) => ( { reset, } ), );
reset();
The acts object contains all the available actions created from options.acts.
const [
state,
{ acts, }
] = useCon( { count: 0 } );
const {
acts,
} = useConSelector( ( { acts, } ) => ( { acts, } ), );
Subscribes to state changes outside useSelector or useConSelector via selector.
Returns function to unsubscribe the listener.
ALERT:When using subscribe, you have to manage when to unsubscribe the listener.
const [
state,
{ subscribe, },
] = useCon( { count: 0 }, );
const {
subscribe,
} = useConSelector( ( { subscribe, } ) => ( { subscribe, } ), );
// Subscribe to state changes
const unsubscribe = subscribe( ( { state, }, ) => {
if (state.count > 100) {
console.log( 'Why is the count so high?' );
notifyCountReached( state.count );
}
}, );
// Later, when you want to stop listening
unsubscribe();
con-estado is built with TypeScript and provides comprehensive type safety throughout your application.
The library leverages TypeScript's type inference to provide a seamless development experience.
The library automatically infers types from your initial state:
// State type is inferred from initialState
const useStore = createConStore( {
user: {
name: 'John',
age: 30,
preferences: {
theme: 'dark' as 'dark' | 'light',
notifications: true
}
},
todos: [
{ id: 1, text: 'Learn con-estado', completed: false }
]
} );
// In components:
function UserProfile() {
// userData is typed as { name: string, age: number }
const userData = useStore( ( { state } ) => ( {
name: state.user.name,
age: state.user.age
} ) );
// Type error if you try to access non-existent properties
const invalid = useStore( ( { state } ) => state.user.invalid ); // Typescript error. Returns `undefined`
}
When using path-based operations, TypeScript ensures you're using valid paths:
function TodoApp() {
const [ state, { set, commit } ] = useCon( {
todos: [{ id: 1, text: 'Learn TypeScript', completed: false }]
} );
// Type-safe path operations
set( 'state.todos[0].completed', true ); // Valid
set( 'state.todos[0].invalid', true ); // Type error - property doesn't exist
// Type-safe commit operations
commit( 'todos', ( { stateProp } ) => {
stateProp[0].completed = true; // Valid
stateProp[0].invalid = true; // Type error
} );
}
For more complex scenarios, you can explicitly define your state types:
interface User {
id: number;
name: string;
email: string;
}
interface AppState {
users: User[];
currentUser: User | null;
isLoading: boolean;
}
// Explicitly typed store
const useStore = createConStore( {
users: [],
currentUser: null,
isLoading: false
} as AppState );
The library exports several utility types to help with type definitions:
import {
ConState, // Immutable state type
ConStateKeys, // String paths for state
ConStateKeysArray, // Array paths for state
ConHistory, // History type
ConHistoryStateKeys // String paths including history
} from 'con-estado';
// Example usage
type MyState = { count: number; user: { name: string } };
type StatePaths = ConStateKeys<MyState>; // 'count' | 'user' | 'user.name'
con-estado uses Mutative for high performance updates,
but there are several techniques you can use to optimize your application further:
Selectors prevent unnecessary re-renders by only updating components when selected data changes:
// BAD: Component re-renders on any state change
function UserCount() {
const [ state ] = useCon( { users: [], settings: {} } );
return <div>User count: {state.users.length}</div>;
}
// GOOD: Component only re-renders when users array changes
function UserCount() {
const [ state, { useSelector } ] = useCon( { users: [], settings: {} } );
const userCount = useSelector( ( { state } ) => state.users.length );
return <div>User count: {userCount}</div>;
}
For expensive computations, memoize the results:
function FilteredList() {
const [ state, { useSelector } ] = useCon( { items: [], filter: '' }, );
// Computation only runs when dependencies change
const filteredItems = useSelector( ( { state } ) => {
console.log( 'Filtering items' );
return state.items.filter(item =>
item.name.includes(state.filter)
).map(item => <div key={item.id}>{item.name}</div>);
});
return (
<div>
{filteredItems}
</div>
);
}
Update only the specific parts of state that change:
// BAD: Creates new references for the entire state tree
set( 'state', { ...state, user: { ...state.user, name: 'New Name' } } );
// GOOD: Only updates the specific path
set( 'state.user.name', 'New Name' );
For performance-critical applications, you can adjust mutation options:
const useStore = createConStore(initialState, {
mutOptions: {
enableAutoFreeze: false, // Disable freezing for better performance
}
});
| Feature | con-estado | Redux |
|---|---|---|
| Boilerplate | Minimal | Requires actions, reducers, etc. |
| Nested Updates | Direct path updates | Requires manual spreading or immer |
| TypeScript | Built-in inference | Requires extra setup |
| Middleware | Lifecycle hooks | Middleware system |
| Learning Curve | Low to moderate | Moderate to high |
| Bundle Size | Small | Larger with ecosystem |
| Performance | Optimized for nested updates | General purpose |
| Feature | con-estado | Zustand |
|---|---|---|
| API Style | React-focused | React + vanilla JS |
| Nested Updates | Built-in path operations | Manual or with immer |
| Selectors | Built-in with type inference | Requires manual memoization |
| History Tracking | Built-in | Not included |
| TypeScript | Deep path type safety | Basic type support |
| Feature | con-estado | Jotai/Recoil |
|---|---|---|
| State Model | Object-based | Atom-based |
| Use Case | Nested state | Fine-grained reactivity |
| Learning Curve | Moderate | Moderate to high |
| Debugging | History tracking | Dev tools |
| Performance | Optimized for objects | Optimized for atoms |
Problem: You've updated state but don't see changes in your component.
Solutions:
- Check if you're using selectors correctly
- Verify that you're not mutating state directly
- Ensure you're using the correct path in path-based operations
// INCORRECT
state.user.name = 'New Name'; // Cannot direct mutate. Typescript will show error.
// CORRECT
set( 'state.user.name', 'New Name');
// OR
commit( 'user', ( { stateProp } ) => {
stateProp.name = 'New Name';
});
// OR
commit( 'user.name', (props) => {
props.stateProp = 'New Name';
});
- Use the history tracking to inspect state changes
- Add logging in lifecycle hooks:
const useStore = createConStore(initialState, {
afterChange: ( history, patches ) => {
console.log( 'State updated:', history.state );
console.log( 'Changes:', patches );
}
});