Skip to content

feat: add mapProps to derive view props from store state and props#102

Open
Olovyannikov wants to merge 3 commits into
effector:masterfrom
Olovyannikov:feat/reflect-map-props
Open

feat: add mapProps to derive view props from store state and props#102
Olovyannikov wants to merge 3 commits into
effector:masterfrom
Olovyannikov:feat/reflect-map-props

Conversation

@Olovyannikov

@Olovyannikov Olovyannikov commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Closes #13

Adds an optional mapProps field to reflect (and createReflect, variant, list) that computes a prop for the view from a store value combined with the component's own props:

reflect({
  view: A,
  bind: { foo: $data },
  mapProps: {
    bar: { source: $data, fn: (data, props) => data[props.key] },
  },
})
  • source is read reactively via useUnit, so the component re-renders only when that store changes
  • fn receives the store value and the props (bound + incoming)
  • derived props are made optional in the resulting component type and can still be overridden explicitly at the usage site (external props win)

Covered by runtime tests (no-ssr + ssr/scope), type tests and docs.

Example:

import { reflect } from '@effector/reflect';
import { Badge, Group, Text } from '@mantine/core';
import { IconShoppingCart } from '@tabler/icons-react';
import { combine } from 'effector';

import { CartModel } from '@/entities/Cart';

interface CartSummaryViewProps {
    /** Incoming prop — a static label set at the usage site. */
    label: string;
    /** Incoming prop — currency suffix set at the usage site. */
    currency: string;
    /** Bound from the store via `bind`. */
    count: number;
    /** Derived from store state + props via `mapProps` (optional at the usage site). */
    summary: string;
}

const CartSummaryView = ({ count, summary }: CartSummaryViewProps) => (
    <Group gap='xs' align='center'>
        <IconShoppingCart size={18} />
        <Text fw={600}>{summary}</Text>
        <Badge color={count > 0 ? 'yellow' : 'gray'} c='black'>
            {count}
        </Badge>
    </Group>
);

/**
 * `source` for `mapProps` is a single store (or object-shaped if needed), so we `combine` the two cart stores
 * we need into one shape store first.
 */
const $cartSummarySource = combine(CartModel.$cartProductsCount, CartModel.$cartTotalFinalPrice, (count, total) => ({
    count,
    total,
}));

/**
 * Example of `@effector/reflect`'s `mapProps`:
 * a prop (`summary`) is derived from store state (`source`) combined with the
 * component's own props (`label`, `currency`).
 *
 * The component re-renders only when `$cartSummarySource` changes.
 */
export const CartSummary = reflect({
    view: CartSummaryView,
    bind: {
        // plain reactive binding — `count` follows the store
        count: CartModel.$cartProductsCount,
    },
    mapProps: {
        summary: {
            source: $cartSummarySource,
            // `cart` is the `source` value (typed not loosely as `any`, so we not necessary to annotate it),
            // `props` are the component's props — contextually typed as the view's props.
            fn: (cart, props) =>
                `${props.label}: ${cart.count} шт. на ${cart.total.toLocaleString('ru-RU')} ${props.currency}`,
        },
    },
});

Adds an optional `mapProps` field to `reflect` (and `createReflect`,
`variant`, `list`) that computes a prop for the view from a store value
combined with the component's own props:

    reflect({
      view: A,
      bind: { foo: $data },
      mapProps: {
        bar: { source: $data, fn: (data, props) => data[props.key] },
      },
    })

- `source` is read reactively via useUnit, so the component re-renders
  only when that store changes
- `fn` receives the store value and the props (bound + incoming)
- derived props are made optional in the resulting component type and
  can still be overridden explicitly at the usage site (external props win)

Covered by runtime tests (no-ssr + ssr/scope), type tests and docs.
@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

Reworks the public types so the `source` stores are captured in a
separate generic (`Sources`). Because the stores are inferred
independently of the `fn`s, the `fn` value argument is now inferred as
the source store value - no manual annotation needed - while `props`
stays typed as the view's props:

    mapProps: {
      label: {
        source: $user,                       // Store<{ name: string }>
        fn: (user, props) => user.name,      // `user` inferred, not `any`
      },
    }

A key that is not a prop of the view resolves the fn return type to
`never`. Applies to reflect, createReflect, variant and list.
`source` now accepts not only a single store but - like `combine` /
`useUnit` - an object or array of stores, so several stores can feed a
derived prop without a manual `combine`:

    mapProps: {
      summary: {
        source: { count: $count, total: $total },
        fn: (cart, props) => `${cart.count} / ${cart.total}`,
      },
    }

The resolved value type is inferred (object -> object of values, array ->
tuple of values). At runtime a non-store source is normalized via
`combine` once, at component creation.
@AlexandrHoroshih AlexandrHoroshih self-requested a review June 23, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Combine store state with props to create prop for view

1 participant