Skip to content

rafde/con-estado

Repository files navigation

con-estado

NPM License NPM Version JSR Version Test

Docs

For full documentation, with details and examples, see con-estado docs.

Installation

npm i con-estado
yarn add con-estado
deno add jsr:@rafde/con-estado

Introduction

con-estado is a state management library built on top of Mutative, like Immer but faster, with the goal of helping with deeply nested state management.

With TypeScript support, strongly infers as much as it can for state and callback functions so you can use type-safe selectors and actions.

Why Use con-estado?

Managing deeply nested state in React often becomes cumbersome with traditional state management solutions. con-estado provides:

  • Direct path updates: Modify nested properties using dot-notation or Array<string | number> instead of spreading multiple levels
  • Referential stability: Only modified portions of state create new references, preventing unnecessary re-renders
  • Custom selectors: Prevent component re-renders by selecting only relevant state fragments
  • Type-safe mutations: Full TypeScript support for state paths and updates

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

Local State

Local State Basic Usage

// Demo
import { ChangeEvent } from 'react';
import { useCon } 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, { setWrap, acts } ] = useCon( initialState, {
    acts: ({ set }) => ({
      onChangeInput: (event: ChangeEvent<HTMLInputElement>) => {
        const name = event.target.name;
        const value = event.target.value;
        console.log('onChangeInput', name, value);
        set(
          event.target.name as unknown as Parameters<typeof set>[0],
          event.target.value
        );
      },
    }),
  });

  return (
    <div>
      <h1>Welcome {state.user.name}</h1>
      <input
        type="text"
        name="user.name"
        value={state.user.name}
        onChange={acts.onChangeInput}
      />
      <button
        onClick={setWrap('user.preferences.notifications.email', (props) => {
          console.log('toggle email was ', props.draft);
          props.draft = !props.draft;
          console.log('toggle email is ', props.draft);
        })}
      >
        Toggle Email Notifications:{' '}
        {state.user.preferences.notifications.email ? 'OFF' : 'ON'}
      </button>
      <select
        value={state.user.preferences.theme}
        name="user.preferences.theme"
        onChange={acts.onChangeInput}
      >
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
    </div>
  );
}

Key advantages:

  • Optimized subscriptions through selector-based consumption

Global Store

For applications needing global state management, createConStore provides a solution for creating actions and optimized updates:

Global Store Basic Usage

// Demo
import { ChangeEvent } from 'react';
import { createConStore } from 'con-estado';

// Define your initial state
const initialState = {
  user: {
    name: 'John',
    preferences: {
      theme: 'light' as 'light' | 'dark',
      notifications: {
        email: true,
      },
    },
  },
};

const useSelector = createConStore( initialState, {
  acts: ({ set }) => ({
    onChangeInput: (
      event: ChangeEvent<HTMLInputElement | HTMLSelectElement>
    ) => {
      const name = event.target.name;
      const value = event.target.value;
      console.log('onChangeInput', name, value);
      set(
        event.target.name as unknown as Parameters<typeof set>[0],
        event.target.value
      );
    },
  }),
});

function App() {
  const [state, { setWrap, acts }] = useSelector();

  return (
    <div>
      <h1>Welcome {state.user.name}</h1>
      <input
        type="text"
        name="user.name"
        value={state.user.name}
        onChange={acts.onChangeInput}
      />
      <button
        onClick={setWrap('user.preferences.notifications.email', (props) => {
          console.log('toggle email was ', props.draft);
          props.draft = !props.draft;
          console.log('toggle email is ', props.draft);
        })}
      >
        Toggle Email Notifications:{' '}
        {state.user.preferences.notifications.email ? 'OFF' : 'ON'}
      </button>
      <select
        value={state.user.preferences.theme}
        name="user.preferences.theme"
        onChange={acts.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

Custom Selectors

Selector is a function that returns the props you need. Only re-renders on non-function changes.

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 Parameter<typeof props.set>[0],
            event.target.value,
          );
        },
      } ),
    );
    return <select
      value={preferences.theme}
      name="user.preferences.theme"
      onChange={preferences.updateTheme}
    >
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>;
}

Actions

Define reusable actions for complex state updates:

function PostList() {
  const [ state, { acts, } ] = useCon(
    { posts: [ { id: 1, text: 'post', } ] },
    {
      acts: ( { currySet, wrapSet, } ) => {
        // currySet is a function that
        // returns a function that can be called with the posts array
        const setPost = currySet('posts');

        return {
          addPost( post: Post, ) {
            setPost( ( { draft } ) => {
              draft.push( post );
            });
          },
          updatePost: wrapSet(
            'posts',
            ( { draft }, id: number, updates: Partial<Post>, ) => {
              const post = draft.find( p => p.id === id );
              if (post) Object.assign( post, updates );
            }
          ),
          async fetchPosts() {
            const posts = await api.getPosts();
            setPost( 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>;
}

State History

Track and access previous state values:

  • state: Current immutable state object.
  • prev: The previous state immutable object before state was updated.
  • initial: Immutable initial state it started as. It can be updated through historyDraft for resync purposes like merging with server data while state keeps client side data.
  • prevInitial: The previous initial immutable object before initial was updated.
  • changes: Immutable object that keeps track of top level properties (shallow) difference between the state and initial 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>;
}

To Don't example

To Don't Example

API References

createConStore and useCon take the same parameters.

1. initial

// works with createConStore
useCon( {} );
useCon( () => ({}) );
useCon( [] );
useCon( () => ([]) );

Used to initialize the state value. non-null Object with data, Array, or a function that returns an Object or Array

2. options

Configuration options for createConStore and useCon.

useCon( initial, options );

2.1. options.acts

Callback function for creating a Record of action handlers. The action handlers have access to a subset of the controls object.

useCon(
  initial,
  {
    acts: ( {
      set,
      currySet,
      setWrap,
      get,
      reset,
      getDraft,
      setHistory,
      currySetHistory,
      setHistoryWrap,
    }: ActControls ) => ( {
      // your actions with async support
      yourAction( props, ) {
        // your code
      }
    } ),
  }
);

2.2. options.afterChange

Async callback after state changes.

useCon(
  initial,
  {
    afterChange(
      { state, initial, prev, prevInitial, changes, }: History
    ) {
      // your code with async support
    }
  }
);

2.3. options.transform

Callback function to transform the state and/or initial properties before it is set/reset. Receives a draft and current history

useCon(
  initial,
  {
    transform: (
      { state, initial, }: HistoryDraft,
      { state, initial, prev, prevInitial, changes, }: History,
      type: 'set' | 'reset',
    ) => {
      // your code
    }
  }
);

2.4. options.mutOptions

Configuration for mutative options.

{enablePatches: true} not supported.

3. selector

Custom selector callback that lets you shape what is returned from useCon and createConStore.

useCon Example:

useCon(
  initialState,
  options,
  ( {
    state,
    acts,
    set,
    currySet,
    setWrap,
    get,
    reset,
    getDraft,
    setHistory,
    currySetHistory,
    setHistoryWrap,
    useSelector, // only available in `useCon`
    subscribe,
  }: UseConControls, ) => unknown // unknown represents the return type of your choice
);

// Example without options
useCon( initialState, selector, );
createConStore(
  initialState,
  options,
  ( {
    state,
    acts,
    set,
    currySet,
    setWrap,
    get,
    reset,
    getDraft,
    setHistory,
    currySetHistory,
    setHistoryWrap,
    subscribe,
  }: CreateConStoreControls, ) => 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.setWrap('count')
    : () => {}
);

// Won't re-render, but it will do something.
const setCount = useCon( initialState, controls => (value) => {
  controls.state.count < 10
   ? controls.set('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.setWrap('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 );

useCon

Local state manager for a React Component

const [ state, controls, ] = useCon( initialState, options, selector, );

useSelector

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 a selector is provided, it returns the result of the selector.
  • 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.setWrap('count')
   : () => {}
);

// Won't re-render, but it will do something.
const setCount = useCon( initialState, controls => (value) => {
  controls.state.count < 10 ? controls.set('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.setWrap('count') : () => {}
}));

createConStore

Global store state manager.

const useConSelector = createConStore( initialState, options, selector, );

useConSelector

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,
  set,
  currySet,
  setWrap,
  get,
  reset,
  setHistory,
  currySetHistory,
  setHistoryWrap,
  subscribe,
}: UseConSelectorControls = useConSelector

If a selector is provided from createConStore or useConSelector, it returns the result of the selector.

const yourSelection = useConSelector(
  ( {
    state,
    acts,
    set,
    currySet,
    setWrap,
    get,
    reset,
    setHistory,
    currySetHistory,
    setHistoryWrap,
    subscribe,
  }, ) => unknown
);

Shared Controls

The following functions

have access to the following controls:

get

Gives you immutable access to State History.

const [
  state,
  { get, }
] = useCon( { count: 0, }, );

const {
  get,
} = useConSelector( ( { get, } ) => ( { get, } ), ) ;

const history = get();
history.state;
history.initial;
history.changes;
history.prev;
history.prevInitial;

You can also use dot-notation to access properties.

const changesToSomeValue = get('changes.to.some.value');

state

The current 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

Updates state with either a new state object or mutation callback.

const [
  state,
  { set, }
] = useCon( { count: 0, }, );

const {
  set,
} = useConSelector( ( { set, } ) => ( { set, }, ));

All set calls returns a new State History object that contains the following properties:

set( state )

Updates state with a new state object.

set( { my: 'whole', data: ['items'], }, );

set( callback )

Updates state with a mutation callback. Callback expects void return type.

set( ( {
  draft,
  historyDraft,
  state,
  prev,
  initial,
  prevInitial,
  changes,
}, ) => {
  draft.value = 5;
  historyDraft.initial.value = 9
}, );

set callback parameters

Contains State History properties plus:

  • draft: The mutable part of the state object that can be modified in the callback.
  • historyDraft: Mutable state and initial object that can be modified in the callback.

set('path.0.to\\.val', value)

Specialized overload for updating state at a specified dot-notated string path with a direct value.

set( 'my.data', [ 'new', 'value', ], );

Array index number as string, example paths.0.name = paths[0].name.

Paths with . (dot) in their name must be escaped, example

const initial = {
  path: {
    'user.name': 'Name',
  },
}; // 'path.user\\.name'

set(['path', 0, 'to.val'], value)

Specialized overload for updating state at a specified array of strings or numbers (for arrays) path with a direct value.

Array path to the state property to update, can have dot notation, e.g. ['items', 0] or ['users', 2, 'address.name']

Callback works the same as set( 'path.to.value', callback )

set( ['string', 'path', 0, 'to.val'], [ 'new', 'value' ] );

set('path' | ['path'], callback)

Specialized overload for updating state at a specified array of strings or numbers (for arrays) or dot-notated string path with a callback function.

set( 'my.data', ( {
  // same as set( callback )
  draft, historyDraft, state, prev, initial, prevInitial, changes,
  stateProp,
  prevProp,
  initialProp,
  prevInitialProp,
  changesProp,
}, ) => {
  draft.value = 5;
  historyDraft.initial.value = 9
}, );

set with path callback parameters

Shares the same parameters as set( callback ), in addition to:

  • draft: The mutable part of the state value relative to path.
    • ALERT: When path leads to a primitive value, you must use mutate draft via non-destructuring.
      • i.e. set( 'path.to.primitive', (props) => props.draft = 5 )
  • stateProp: The current immutable state value relative to path.
  • initialProp: The initial immutable value relative to path.
  • prevProp: The previous immutable state value relative to path. Can be undefined.
  • prevInitialProp: The previous immutable initial value relative to path. Can be undefined.
  • changesProp: Immutable changed value made to the state value relative to path. Can be undefined.

acts

The acts object contains all the available actions created from options.acts.

const [
  state,
  { acts, }
] = useCon( { count: 0 } );

const {
  acts,
} = useConSelector( ( { acts, } ) => ( { acts, } ), );

setWrap

A convenient function that lets you wrap set around another function that accepts any number of arguments and can return any value from it.

const [
  state,
  { setWrap, }
] = useCon( { count: 0, }, );

const {
  setWrap,
} = useConSelector( ( { setWrap, } ) => ( { setWrap, } ), );

// Example usage
const inc = setWrap( 
  ( { draft, }, incBy: number, ) => draft.count += incBy
);

const newInc = inc( 5, ); // returns 5

// Example usage
const incCount = setWrap( 
  'count',
  ( props, incBy: number, ) => props.draft += incBy
);

const newCount = inc( 5, ); // returns 5

The first parameter can be

  • a callback
  • dot-notated string, or array of string or numbers for state prop path, followed by a callback.

currySet

Creates a pre-bound set function for a specific dot-notation string path. Enables partial application of path for reusable state updaters

const [
  state,
  { currySet, },
] = useCon( { count: 0, }, );

const {
  currySet,
} = useConSelector( ( { currySet, } ) => ( { currySet, } ), );

const setCount = currySet( 'count', );
setCount( 5, );
setCount( ( props, ) => props.draft += 1 );

setHistory

Works like set, but can be used to update both state and initial.

setHistoryWrap

Works like setWrap, but can be used to update both state and initial.

currySetHistory

Works like currySet, but can be used to update both state and initial.

reset

Resets state to initial. Returns State History with initial set to state values.

const [
  state,
  { reset, },
] = useCon( { count: 0, }, );

const {
  reset,
} = useConSelector( ( { reset, } ) => ( { reset, } ), );

reset();

subscribe

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();

Credits to

  • Mutative for efficient immutable updates
  • Immer for inspiring Mutative
  • Zustand for the inspiration
  • Øivind Loe for reminding me why I wanted to create a state management library.