Managing deeply nested state in React often becomes cumbersome with traditional state management solutions. con-estado
provides:
- 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-estado
is particularly useful for applications with:- Complex nested state structures
- Performance-sensitive state operations
- Frequent partial state updates
- Teams wanting to reduce state management boilerplate
For documentation with working examples, see con-estado docs.
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:
// Demo
import { ChangeEvent } from 'react';
import { createConStore, ConHistoryStateKeys, } from 'con-estado';
// Define your initial state
const initialState = {
user: {
name: 'John',
preferences: {
theme: 'light' as 'light' | 'dark',
notifications: {
email: true,
},
},
},
};
const useConSelector = createConStore( initialState, {
acts: ({ set }) => ({
onChangeInput: (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const name = event.target.name as ConHistoryStateKeys<
typeof initialState
>;
const value = event.target.value;
console.log( 'onChangeInput', name, value );
set(
name,
value,
);
},
}),
});
function App() {
const state = useConSelector( 'state' );
// Does not recreate handler
const onClickButton = useConSelector( ( controls ) => controls.wrap(
'user.preferences.notifications.email',
( props ) => {
console.log( 'toggle email was ', props.stateProp);
props.stateProp = !props.stateProp;
console.log( 'toggle email is ', props.stateProp);
}
) );
const onChangeInput = useConSelector.acts.onChangeInput;
return (
<div>
<h1>Welcome {state.user.name}</h1>
<input
type="text"
name="state.user.name"
value={state.user.name}
onChange={onChangeInput}
/>
<button
onClick={onClickButton}
>
Toggle Email Notifications:{' '}
{state.user.preferences.notifications.email ? 'OFF' : 'ON'}
</button>
<select
value={state.user.preferences.theme}
name="state.user.preferences.theme"
onChange={onChangeInput}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
);
}
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:
// Demo
import { ChangeEvent } from 'react';
import { useCon, ConHistoryStateKeys, } from 'con-estado';
// Define your initial state
const initialState = {
user: {
name: 'John',
preferences: {
theme: 'light' as 'light' | 'dark',
notifications: {
email: true,
},
},
},
};
function App() {
const [ state, { useSelector, } ] = useCon( initialState, {
acts: ( { set } ) => ({
onChangeInput: (
event: ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const name = event.target.name as ConHistoryStateKeys<
typeof initialState
>;
const value = event.target.value;
console.log('onChangeInput', name, value);
set(name, value);
},
}),
});
const onChangeInput = useSelector( 'acts.onChangeInput' );
// Does not recreate handler
const onClickButton = useSelector( ( controls ) =>
controls.wrap( 'user.preferences.notifications.email', ( props ) => {
console.log('toggle email was ', props.stateProp);
props.stateProp = !props.stateProp;
console.log('toggle email is ', props.stateProp);
})
);
return (
<div>
<h1>Welcome {state.user.name}</h1>
<input
type="text"
name="state.user.name"
value={state.user.name}
onChange={onChangeInput}
/>
<button
onClick={onClickButton}
>
Toggle Email Notifications:{' '}
{state.user.preferences.notifications.email ? 'OFF' : 'ON'}
</button>
<select
value={state.user.preferences.theme}
name="state.user.preferences.theme"
onChange={onChangeInput}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
);
}
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
state
object beforestate
was updated. - initial: Immutable initial state it started as. It can be updated through
historyDraft
for re-sync purposes, such as merging server data intoinitial
whilestate
keeps latest client side data. - prevInitial: The previous immutable
initial
object beforeinitial
was updated. - changes: Immutable object that keeps track of deeply nested difference between the
state
andinitial
object.
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
state
andinitial
that 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
state
and/orinitial
. Useful for when you want to include additional changes based on whatpatches
contains.
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 aselector
is 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 function
s
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(
10000
'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 object
s/array
s:
// 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
state
object that can be modified in the callback. - initial: Mutable
initial
object that can be modified in the callback. - prev: Immutable previous
state
object (undefined
on first update). - prevInitial: Immutable previous
initial
object (undefined
on first update). - changes: Immutable changes made to state (
undefined
on 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 object
s or array
s 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
state
value relative to path. - initialProp: Mutable
initial
value relative to path. - prevProp: Immutable
prev
value relative to path. Can beundefined
. - prevInitialProp: Immutable
prevInitial
value relative to path. Can beundefined
. - changesProp: Immutable
changes
value 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 object
s/array
s 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 string
s or number
s (for array
s).
// 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 object
s/array
s:
// 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
state
object that can be modified in the callback. - initial: Mutable
initial
object that can be modified in the callback. - prev: Immutable previous
state
object. - prevInitial: Immutable previous
initial
object. - 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 object
s or array
s 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
state
value relative to path. - initialProp: Mutable
initial
value relative to path. - prevProp: Immutable
prev
value relative to path. Can beundefined
. - prevInitialProp: Immutable
prevInitial
value relative to path. Can beundefined
. - changesProp: Immutable
changes
value 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 );
}
});