effector icon indicating copy to clipboard operation
effector copied to clipboard

useStoreMap: Simpler method to get a partial object/array

Open jsejcksn opened this issue 4 years ago • 12 comments

I'd like to just get certain properties on an object. This seems very verbose to accomplish the task:

const pick = <T, K extends keyof T>(
  obj: T,
  keys: readonly K[],
): Pick<T, K> => keys.reduce((acc, key) => {
  acc[key] = obj[key];
  return acc;
}, {} as Pick<T, K>);
const partialState = useStoreMap({
  fn: pick,
  keys: ['firstKey', 'secondKey'] as const,
  store: exampleObjectStore, // Record<string, T>
});

Is there a simpler, type-safe way to get a partial object? Something like this would be ideal:

const partialState = useStoreMap({
  keys: ['firstKey', 'secondKey'],
  store: exampleObjectStore, // Record<string, T>
});

It would work with arrays in the same way:

const partialState = useStoreMap({
  keys: [1, 3],
  store: exampleArrayStore, // T[]
});

jsejcksn avatar Nov 18 '20 22:11 jsejcksn

You can read your fields directly with useStore

const $user = createStore({name: 'alice', age: 22})

export const User = () => {
  const {name, age} = useStore($user)
  ...
}

Objects in your store could be already prepared for your use case, that’s the case of store.map and combine

zerobias avatar Nov 19 '20 03:11 zerobias

@zerobias Thank you for responding.

You can read your fields directly with useStore

const $user = createStore({name: 'alice', age: 22})

export const User = () => {
  const {name, age} = useStore($user)
  ...
}

In this example, you are using every property in the state (name and age).

Instead, if:

  • you wrote const {name} = useStore($user), and
  • the store was updated with a different value for age but the same value for name

would the component re-render or not?

Objects in your store could be already prepared for your use case, that’s the case of store.map and combine

These methods still require writing a function, which is what this feature request is about trying to avoid. It would be nice to have a simple way to get a slice of an object by just passing the store and property accessors (keys) only, without any function.

jsejcksn avatar Nov 19 '20 05:11 jsejcksn

Store is updated when it receives a value that is not equal (!==) to current one and to undefined, so component will rerender even with your pick function.

But you might prepare your data with store.map: it has a second argument with current store value:

const $user = createStore({name: 'alice', age: 22, email: '[email protected]'})

const $userTitle = updateBy($user, ['name', 'email'])

export const UserTitle = () => {
  const {name, email} = useStore($userTitle)
  ...
}

function updateBy(sourceStore, fields) {
  return sourceStore.map((update, currentValue) => {
    if (!currentValue) return update
    for (const field of fields) {
      if (update[field] !== currentValue[field])
        return update
    }
  })
}

I'm found that documentation doesn't mention store update conditions, so I added it to Store article

zerobias avatar Nov 19 '20 20:11 zerobias

New features are added to the library when there are enough feature requests from users, as almost every project is unique with its own unique demands, including updates by only part of the store.

zerobias avatar Nov 19 '20 20:11 zerobias

Also, usually complex stores are composed from a simple ones with combine, so if you split your store and combine only required fields, then it will trigger rerender only when needed:

const $name = createStore('alice')
const $age = createStore(21)
const $email = createStore('[email protected]')

const $userTitle = combine({
  age: $age,
  email: $email
})

zerobias avatar Nov 19 '20 20:11 zerobias

@zerobias Thanks for providing those examples.

In this case, I am in need of the opposite of combine: I need to only receive updates when selected keys of the store object change. How can I accomplish that?

jsejcksn avatar Nov 21 '20 02:11 jsejcksn

Split monolithic single store with irrelevant fields to atomic stores and combine only necessary ones. If your use cases assumes that you need more than one combination then just make another combine call. That's why atomic stores exists in a first place

zerobias avatar Nov 21 '20 04:11 zerobias

Thanks, again.

Split monolithic single store with irrelevant fields to atomic stores and combine only necessary ones. If your use cases assumes that you need more than one combination then just make another combine call. That's why atomic stores exists in a first place

🤔 I was hoping this is not the only way.

I am receiving this "monolithic" store from a module that I don't control. It would be very nice to be able to "pick" or "select" property values from an object store in cases like this.

Initially, I thought that's what useStoreMap might let me do, but now I realize that it's not currently intended for that purpose.

Considering this concept, will you please regard this issue as a feature request for that purpose? If you are open to this feature request, then I have more thoughts to share about the functionality and API for it.

jsejcksn avatar Nov 21 '20 04:11 jsejcksn

Here's an example of what describing (the partialStore function), but it only works for for objects (not arrays), and only top level keys (not nested ones):

const pick = <T, K extends keyof T>(
  obj: T,
  keys: readonly K[],
): Pick<T, K> => keys.reduce((acc, key) => {
    acc[key] = obj[key];
    return acc;
  }, {} as Pick<T, K>);

const partialStore = <T, K extends keyof T>(
  store: Store<T>,
  keys: readonly K[],
): Store<Pick<T, K>> => store.map((state, lastState?: Pick<T, K>) => {
    if (typeof lastState === 'undefined') return pick(state, keys);
    for (const key of keys) {
      if (state[key] !== lastState[key]) return pick(state, keys);
    }
    return lastState;
  });
example.deno.ts:
// @deno-types='https://unpkg.com/[email protected]/effector.mjs.d.ts'
import {
  combine,
  createEvent,
  createStore,
  Store,
} from 'https://unpkg.com/[email protected]/effector.mjs';

const pick = <T, K extends keyof T>(
  obj: T,
  keys: readonly K[],
): Pick<T, K> => keys.reduce((acc, key) => {
    acc[key] = obj[key];
    return acc;
  }, {} as Pick<T, K>);

const partialStore = <T, K extends keyof T>(
  store: Store<T>,
  keys: readonly K[],
): Store<Pick<T, K>> => store.map((state, lastState?: Pick<T, K>) => {
    if (typeof lastState === 'undefined') return pick(state, keys);
    for (const key of keys) {
      if (state[key] !== lastState[key]) return pick(state, keys);
    }
    return lastState;
  });

const replaceWithValue = <T>(state: T, value: T): T => value;

const defaultState = {
  item: 'bagel',
  qty: 2,
  type: 'plain',
};

const setItem = createEvent<string>('setItem');
const setQty = createEvent<number>('setQty');
const setType = createEvent<string>('setType');

const $item = createStore(defaultState.item).on(setItem, replaceWithValue);
const $qty = createStore(defaultState.qty).on(setQty, replaceWithValue);
const $type = createStore(defaultState.type).on(setType, replaceWithValue);

const $obj = combine({item: $item, qty: $qty, type: $type});

const $partial = partialStore($obj, ['qty', 'type']);

$obj.watch(state => console.log('$obj', state));
// $obj { item: "bagel", qty: 2, type: "plain" }

$partial.watch(state => console.log('$partial', state));
// $partial { qty: 2, type: "plain" }

setQty(1);
// $obj { item: "bagel", qty: 1, type: "plain" }
// $partial { qty: 1, type: "plain" }

setItem('criossant');
// $obj { item: "criossant", qty: 1, type: "plain" }

setType('cinnamon');
// $obj { item: "criossant", qty: 1, type: "cinnamon" }
// $partial { qty: 1, type: "cinnamon" }

jsejcksn avatar Nov 22 '20 04:11 jsejcksn

const $partial = combine({qty: $qty, type: $type})

You just don’t need any intermediate $obj store for that task.

Even more, combine support arrays too:

const $qtyTypeTuple = combine([$qty, $type])

zerobias avatar Nov 22 '20 16:11 zerobias

Of course, it is just for example. But like I said before:

I am receiving this "monolithic" store from a module that I don't control. It would be very nice to be able to "pick" or "select" property values from an object store in cases like this.

In the example, $obj is the store I am receiving from the module, and it is the only store the module exports.

jsejcksn avatar Nov 22 '20 16:11 jsejcksn

You can review patronum/spread method for your case.

const $first = createStore("")
const $second = createStore("")

const receiveMonolith = createEvent()

spread({
  source: receiveMonolith,
  targets: {
    first: $first,
    second: $second,
  },
})

receiveMonolith({
  first: "Hello",
  second: "World",
  id: 123,
})

sergeysova avatar Dec 07 '20 18:12 sergeysova