Skip to content

add mapProps for reflect() to derive view props from store state and props#102

Merged
AlexandrHoroshih merged 3 commits into
effector:masterfrom
Olovyannikov:feat/reflect-map-props
Jun 25, 2026
Merged

add mapProps for reflect() to derive view props from store state and props#102
AlexandrHoroshih merged 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

@AlexandrHoroshih AlexandrHoroshih left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome work, thank you!

@AlexandrHoroshih AlexandrHoroshih merged commit cdcbe32 into effector:master Jun 25, 2026
@sergeysova sergeysova changed the title feat: add mapProps to derive view props from store state and props add mapProps for reflect() to derive view props from store state and props Jun 25, 2026
@sergeysova sergeysova added the enhancement New feature or request label Jun 25, 2026
AlexandrHoroshih added a commit that referenced this pull request Jun 25, 2026
Follow-up to PR #102 (mapProps feature). Addresses all review points:

#1 — Runtime tests added for variant, list, createReflect (no-ssr + ssr).
      Previously only reflect had mapProps coverage.
#2 — Unknown mapProps keys now error at the key site itself, not on fn's
      return. MapPropsFromSources resolves unknown-key entries to never,
      producing a TS2322 at the key line. Replaces the old
      'K extends keyof Props ? Props[K] : never' fn-return approach that
      had a silent-failure path (never-returning fn compiled cleanly).
#3 — Documented the Store<any> variable-source widening limitation.
      Type tests (3a positive, 3b widening) pin the current behavior.
#4 — fn is skipped when its key is overridden by an external prop.
      'if (key in props) continue' in src/core/reflect.ts. Spy assertions
      in reflect and createReflect tests verify fn is not called.
B1 — bind + mapProps key collision is now a type error. MapPropsFromSources
      takes Bind as a type parameter; a key in both bind and mapProps
      resolves to never. Runtime skip ('if (key in storeProps) continue')
      is a defense-in-depth for JS/type-bypass scenarios (covers stores;
      events/data/functions are covered by the type fix).
B2 — mapItem + mapProps key collision in list is a type error. MapItem's
      mapped type now omits keyof Sources alongside keyof Bind. Partial:
      bypassable with explicit item-parameter annotation (known TS
      limitation with mapped-type extends constraints), documented in the
      type-test comment.

All four operators (reflect, createReflect, list, variant) are covered
by the type fixes and runtime tests.

Docs updated in docs/pages/docs/reflect.mdx:
- mapProps keys must be props of the view; unknown keys error at the key
- a key must not appear in both bind and mapProps
- in list, a key must not appear in both mapItem and mapProps
- source should be an inline literal for best inference
- fn is not invoked when its key is overridden

80 runtime tests + type tests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Combine store state with props to create prop for view

3 participants