├── docs ├── pages │ ├── .gitignore │ ├── _app.mdx │ ├── docs │ │ ├── _meta.json │ │ ├── index.mdx │ │ ├── hooks.mdx │ │ ├── fromTag.mdx │ │ ├── create-reflect.mdx │ │ ├── variant.mdx │ │ ├── list.mdx │ │ └── reflect.mdx │ ├── _meta.json │ ├── learn │ │ ├── installation.mdx │ │ ├── testing.mdx │ │ └── motivation.mdx │ └── index.mdx ├── style.css ├── next-env.d.ts ├── postcss.config.js ├── tailwind.config.js ├── next.config.js ├── package.json ├── tsconfig.json └── theme.config.tsx ├── .lintstagedrc.json ├── .husky └── pre-commit ├── .prettierignore ├── type-tests ├── .eslintrc ├── tsconfig.json ├── types-create-reflect.tsx ├── types-from-tag.ts ├── types-list.tsx ├── types-variant.tsx └── types-reflect.tsx ├── babel.config.json ├── .eslintignore ├── src ├── core │ ├── index.ts │ ├── fromTag.ts │ ├── types.ts │ ├── variant.ts │ ├── list.ts │ └── reflect.ts ├── index.ts ├── scope.ts ├── ssr │ ├── create-reflect.test.tsx │ ├── variant.test.tsx │ ├── reflect.test.tsx │ └── list.test.tsx └── no-ssr │ ├── create-reflect.test.tsx │ ├── variant.test.tsx │ ├── list.test.tsx │ └── reflect.test.tsx ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── .github ├── workflows │ ├── release-drafter.yml │ ├── test.yml │ ├── docs.yml │ └── publish.yml └── release-drafter.yml ├── .prettierrc ├── .config └── tsconfig.base.json ├── validate_dist.mjs ├── vitest.config.mts ├── rollup.config.cjs ├── Readme.md ├── package.json └── public-types └── reflect.d.ts /docs/pages/.gitignore: -------------------------------------------------------------------------------- 1 | !*.md 2 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,mjs,cjs,ts,tsx}": ["prettier --write", "eslint --fix"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist-test 2 | ./core 3 | node_modules 4 | dist 5 | .idea 6 | .vscode 7 | .husky 8 | .github 9 | -------------------------------------------------------------------------------- /type-tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "@typescript-eslint/consistent-type-definitions": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-typescript", "@babel/preset-react"], 3 | "plugins": ["@babel/plugin-transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/pages/_app.mdx: -------------------------------------------------------------------------------- 1 | import '../style.css' 2 | 3 | export default function App({ Component, pageProps }) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | type-tests 2 | node_modules 3 | dist-test 4 | core 5 | .config 6 | .github 7 | /index.d.ts 8 | ./reflect.* 9 | ./scope.* 10 | ./ssr.* 11 | -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @tailwind variants; 5 | 6 | body { 7 | font-feature-settings: 'rlig' 1, 'calt' 1; 8 | } 9 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export { reflectFactory, reflectCreateFactory } from './reflect'; 2 | export { variantFactory } from './variant'; 3 | export { listFactory } from './list'; 4 | export { fromTag } from './fromTag'; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .vscode 4 | 5 | yarn-error.log 6 | 7 | /dist 8 | /ssr/dist 9 | /typings 10 | /browser 11 | /no-ssr 12 | /ssr 13 | 14 | effector-reflect-* 15 | .idea 16 | docs/.next 17 | docs/out 18 | -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /docs/pages/docs/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "Overview", 3 | "reflect": "reflect", 4 | "variant": "variant", 5 | "list": "list", 6 | "create-reflect": "createReflect", 7 | "hooks": "Reflect Hooks", 8 | "fromTag": "fromTag" 9 | } 10 | -------------------------------------------------------------------------------- /src/core/fromTag.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import { createElement } from 'react'; 3 | 4 | export function fromTag(htmlTag: HtmlTag) { 5 | return (props: Record): ReactNode => { 6 | return createElement(htmlTag, props); 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@effector/reflect": ["./public-types/reflect.d.ts"], 7 | "@effector/reflect/scope": ["./public-types/reflect.d.ts"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | // If you want to use other PostCSS plugins, see the following: 2 | // https://tailwindcss.com/docs/using-with-preprocessors 3 | /** @type {import('postcss').Postcss} */ 4 | module.exports = { 5 | plugins: { 6 | tailwindcss: {}, 7 | autoprefixer: {}, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { configure, presets } = require('eslint-kit'); 2 | 3 | module.exports = configure({ 4 | root: __dirname, 5 | presets: [ 6 | presets.react(), 7 | // presets.effector(), 8 | presets.typescript(), 9 | presets.prettier(), 10 | presets.node(), 11 | ], 12 | }); 13 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './components/**/*.{js,ts,jsx,tsx}', 6 | './theme.config.tsx', 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | darkMode: 'class', 13 | }; 14 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: release-drafter/release-drafter@v5 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /docs/next.config.js: -------------------------------------------------------------------------------- 1 | const nextra = require('nextra'); 2 | 3 | const withNextra = nextra({ 4 | theme: 'nextra-theme-docs', 5 | themeConfig: './theme.config.tsx', 6 | flexsearch: { 7 | codeblocks: false, 8 | }, 9 | defaultShowCopyCode: true, 10 | }); 11 | 12 | module.exports = withNextra({ 13 | output: 'export', 14 | images: { 15 | unoptimized: true, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": { 3 | "title": "Home", 4 | "theme": { 5 | "breadcrumb": false, 6 | "footer": true, 7 | "sidebar": false, 8 | "toc": false, 9 | "pagination": false 10 | }, 11 | "display": "hidden" 12 | }, 13 | "learn": { 14 | "title": "Learn", 15 | "type": "page" 16 | }, 17 | "docs": { 18 | "type": "page", 19 | "title": "API" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/pages/docs/index.mdx: -------------------------------------------------------------------------------- 1 | # API Docs 2 | 3 | import { Cards, Card } from "nextra-theme-docs"; 4 | 5 | Effector Reflect provides 3 main methods and a single factory: 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 86, 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "all", 8 | "useTabs": false, 9 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 10 | "importOrder": ["^node:", "", "^[./]"], 11 | "importOrderSeparation": true, 12 | "importOrderSortSpecifiers": true, 13 | "importOrderCaseInsensitive": true 14 | } 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as context from 'effector-react'; 2 | 3 | import { 4 | fromTag, 5 | listFactory, 6 | reflectCreateFactory, 7 | reflectFactory, 8 | variantFactory, 9 | } from './core'; 10 | 11 | export const reflect = reflectFactory(context); 12 | export const createReflect = reflectCreateFactory(context); 13 | 14 | export const variant = variantFactory(context); 15 | 16 | export const list = listFactory(context); 17 | 18 | export { fromTag }; 19 | -------------------------------------------------------------------------------- /.config/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "es2015", 5 | "strict": true, 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": true, 9 | "jsx": "react", 10 | "esModuleInterop": true, 11 | "types": ["vitest/globals"], 12 | "strictNullChecks": true 13 | }, 14 | "include": ["../src"], 15 | "exclude": ["../typings"] 16 | } 17 | -------------------------------------------------------------------------------- /validate_dist.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { dirname, resolve } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | import 'zx/globals'; 6 | 7 | const __dirname = dirname(fileURLToPath(import.meta.url)); 8 | 9 | const pkgDir = resolve(__dirname, './dist'); 10 | try { 11 | const attwResult = await $`pnpm attw --pack ${pkgDir}`; 12 | } catch (error) {} 13 | console.log(); 14 | try { 15 | const publintResult = await $`pnpm publint ${pkgDir}`; 16 | } catch (error) {} 17 | -------------------------------------------------------------------------------- /src/scope.ts: -------------------------------------------------------------------------------- 1 | import * as effectorReactSSR from 'effector-react/scope'; 2 | 3 | import { 4 | listFactory, 5 | reflectCreateFactory, 6 | reflectFactory, 7 | variantFactory, 8 | } from './core'; 9 | 10 | console.error( 11 | '`@effector/reflect/scope` is deprecated, use main `@effector/reflect` package instead', 12 | ); 13 | 14 | export const reflect = reflectFactory(effectorReactSSR); 15 | export const createReflect = reflectCreateFactory(effectorReactSSR); 16 | 17 | export const variant = variantFactory(effectorReactSSR); 18 | 19 | export const list = listFactory(effectorReactSSR); 20 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@effector/reflect-docs", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build" 7 | }, 8 | "packageManager": "pnpm@7.27.0", 9 | "dependencies": { 10 | "next": "^13.4.5", 11 | "nextra": "^2.7.1", 12 | "nextra-theme-docs": "^2.7.1", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "20.3.1", 18 | "autoprefixer": "^10.4.14", 19 | "eslint": "^8.42.0", 20 | "postcss": "^8.4.24", 21 | "tailwindcss": "^3.3.2", 22 | "typescript": "^5.1.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": false, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "include": [ 22 | "next-env.d.ts", 23 | "**/*.ts", 24 | "**/*.tsx" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /type-tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "allowUnreachableCode": false, 6 | "module": "esnext", 7 | "moduleResolution": "Node", 8 | "noEmit": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noUnusedLocals": false, 14 | "strict": true, 15 | "skipLibCheck": true, 16 | "jsx": "react", 17 | "strictNullChecks": true, 18 | "paths": { 19 | "@effector/reflect": ["../public-types/reflect.d.ts"], 20 | "@effector/reflect/scope": ["../public-types/reflect.d.ts"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test Package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test-package: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | 12 | - name: Setup pnpm 13 | uses: pnpm/action-setup@v2 14 | 15 | - name: Use Node.js 18.x 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 18.x 19 | cache: 'pnpm' 20 | 21 | - name: Install dependencies 22 | run: pnpm install --frozen-lockfile 23 | 24 | - name: Build 25 | run: pnpm build 26 | 27 | - name: Run tests 28 | run: pnpm test 29 | env: 30 | CI: true 31 | - name: Validate package 32 | run: pnpm validate:dist 33 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { dirname, resolve } from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import { defineConfig } from 'vitest/config'; 5 | import { $ } from 'zx'; 6 | 7 | if (!process.env.CI) { 8 | /** 9 | * Vitest tests are always run against the built package. 10 | */ 11 | console.log('Building the package...'); 12 | await $`pnpm build`; 13 | $.log = () => {}; 14 | } 15 | 16 | const __dirname = dirname(fileURLToPath(import.meta.url)); 17 | 18 | export default defineConfig({ 19 | test: { 20 | globals: true, 21 | environment: 'jsdom', 22 | }, 23 | plugins: [react()], 24 | resolve: { 25 | alias: { 26 | '@effector/reflect/scope': resolve(__dirname, './dist/scope.mjs'), 27 | '@effector/reflect': resolve(__dirname, './dist/index.mjs'), 28 | }, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /docs/pages/learn/installation.mdx: -------------------------------------------------------------------------------- 1 | # Welcome to @effector/reflect 2 | 3 | ☄️ Attach effector stores to react components without hooks. 4 | 5 | ```sh 6 | npm install @effector/reflect 7 | # or 8 | pnpm add @effector/reflect 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```ts 14 | import { reflect } from '@effector/reflect' 15 | import { TextInput } from '@ui/lib'; 16 | 17 | import { $email, emailChanged, $formDisabled } from './model' 18 | 19 | // Let's define bound component 20 | const EmailField = reflect({ 21 | view: TextInput, 22 | bind: { 23 | value: $email, 24 | onChange: emailChanged, 25 | disabled: $formDisabled, 26 | }, 27 | }) 28 | ``` 29 | 30 | Now you can safely use it as common React-component: 31 | 32 | ```tsx 33 | export const LoginForm: React.FC = () => { 34 | return ( 35 | 36 | 37 | 38 | ) 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: '⚠️ Breaking changes' 3 | label: 'BREAKING CHANGES' 4 | 5 | - title: '🚀 Features' 6 | labels: 7 | - 'feature' 8 | - 'enhancement' 9 | 10 | - title: '🐛 Bug Fixes' 11 | labels: 12 | - 'fix' 13 | - 'bugfix' 14 | - 'bug' 15 | 16 | - title: '🧰 Maintenance' 17 | labels: 18 | - 'chore' 19 | - 'dependencies' 20 | 21 | - title: '📚 Documentation' 22 | label: 'documentation' 23 | 24 | - title: '🧪 Tests' 25 | label: 'tests' 26 | 27 | - title: '🏎 Optimizations' 28 | label: 'optimizations' 29 | 30 | version-resolver: 31 | major: 32 | labels: 33 | - 'BREAKING CHANGES' 34 | minor: 35 | labels: 36 | - 'feature' 37 | - 'enhancement' 38 | patch: 39 | labels: 40 | - 'fix' 41 | default: patch 42 | 43 | name-template: 'v$RESOLVED_VERSION' 44 | tag-template: 'v$RESOLVED_VERSION' 45 | 46 | change-template: '- $TITLE #$NUMBER (@$AUTHOR)' 47 | template: | 48 | $CHANGES 49 | -------------------------------------------------------------------------------- /docs/theme.config.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import { useRouter } from 'next/router'; 3 | import { type DocsThemeConfig } from 'nextra-theme-docs'; 4 | 5 | const config: DocsThemeConfig = { 6 | logo: @effector/reflect, 7 | project: { 8 | link: 'https://github.com/effector/reflect', 9 | }, 10 | docsRepositoryBase: 'https://github.com/effector/reflect/blob/master/docs', 11 | darkMode: true, 12 | primaryHue: 35, 13 | navbar: { 14 | // extraContent: <>Link to Effector Docs>, 15 | }, 16 | footer: { 17 | text: ( 18 | 19 | MIT {new Date().getFullYear()} ©{' '} 20 | 21 | Effector Core Team 22 | 23 | 24 | ), 25 | }, 26 | faviconGlyph: '☄️', 27 | useNextSeoProps() { 28 | const { asPath } = useRouter(); 29 | if (asPath !== '/') { 30 | return { 31 | titleTemplate: '%s | @effector/reflect', 32 | }; 33 | } 34 | }, 35 | }; 36 | 37 | export default config; 38 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import { Effect, EventCallable, Store } from 'effector'; 2 | import { useList, useUnit } from 'effector-react'; 3 | import { ComponentType } from 'react'; 4 | 5 | /** 6 | * This is the internal typings - for the library internals, where we do not really care about real-world user data. 7 | * 8 | * Public types are stored separately from the source code, so it is easier to develop and test them. 9 | * You can find public types in `public-types` folder and tests for type inference at the `type-tests` folder. 10 | */ 11 | 12 | export interface Context { 13 | useUnit: typeof useUnit; 14 | useList: typeof useList; 15 | } 16 | 17 | export type View = ComponentType; 18 | 19 | export type BindProps = { 20 | [K in keyof Props]: Props[K] | Store | EventCallable; 21 | }; 22 | 23 | export type Hook = 24 | | ((props: Props) => any) 25 | | EventCallable 26 | | Effect; 27 | 28 | export type Hooks = { 29 | mounted?: Hook; 30 | unmounted?: Hook; 31 | }; 32 | 33 | export type UseUnitConifg = Parameters[1]; 34 | -------------------------------------------------------------------------------- /docs/pages/docs/hooks.mdx: -------------------------------------------------------------------------------- 1 | 2 | # Reflect Hooks 3 | 4 | Hooks is an object passed to `reflect()`, or `variant()`, etc. with properties `mounted` and `unmounted` all optional. 5 | 6 | Both `mounted` and `unmounted` can be either function or Effector's Event. Reflect will call provided function with rendered props of the component. 7 | 8 | ## Example 9 | 10 | ```tsx 11 | import { reflect, variant } from '@effector/reflect'; 12 | import { Range, TextInput } from '@org/my-ui'; 13 | import { createEvent, createStore } from 'effector'; 14 | 15 | const $type = createStore<'text' | 'range'>('text'); 16 | const $value = createStore(''); 17 | const valueChange = createEvent(); 18 | const rangeMounted = createEvent(); 19 | const fieldMounted = createEvent(); 20 | 21 | const RangePrimary = reflect({ 22 | view: Range, 23 | bind: { style: 'primary' }, 24 | hooks: { mounted: rangeMounted }, 25 | }); 26 | 27 | const Field = variant({ 28 | source: $type, 29 | bind: { value: $value, onChange: valueChange }, 30 | cases: { 31 | text: TextInput, 32 | range: RangePrimary, 33 | }, 34 | hooks: { mounted: fieldMounted }, 35 | }); 36 | ``` 37 | 38 | When `Field` is mounted, `fieldMounted` and `rangeMounted` would be called. 39 | -------------------------------------------------------------------------------- /type-tests/types-create-reflect.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { createReflect } from '@effector/reflect'; 3 | import { Button } from '@mantine/core'; 4 | import { createEvent, createStore } from 'effector'; 5 | import React from 'react'; 6 | import { expectType } from 'tsd'; 7 | 8 | // basic createReflect 9 | { 10 | const Input: React.FC<{ 11 | value: string; 12 | onChange: (newValue: string) => void; 13 | color: 'red'; 14 | }> = () => null; 15 | const $value = createStore(''); 16 | const changed = createEvent(); 17 | 18 | const reflectInput = createReflect(Input); 19 | 20 | const ReflectedInput = reflectInput({ 21 | value: $value, 22 | onChange: changed, 23 | color: 'red', 24 | }); 25 | 26 | expectType(ReflectedInput); 27 | } 28 | 29 | // Edge-case: Mantine Button with weird polymorphic factory 30 | { 31 | const clicked = createEvent(); 32 | 33 | const reflectButton = createReflect(Button<'button'>); 34 | 35 | const ReflectedButton = reflectButton({ 36 | children: 'Click me', 37 | color: 'red', 38 | onClick: (e) => clicked(e.clientX), 39 | }); 40 | 41 | ; 42 | } 43 | -------------------------------------------------------------------------------- /docs/pages/docs/fromTag.mdx: -------------------------------------------------------------------------------- 1 | # `fromTag` 2 | 3 | ```ts 4 | import { fromTag } from '@effector/reflect'; 5 | ``` 6 | 7 | ```ts 8 | const DomInput = fromTag('input'); 9 | ``` 10 | 11 | **This feature is available since `9.0.0` release of Reflect.** 12 | 13 | Helper to simplify `reflect` usage with pure DOM elements by creating simple components based on a html tag. 14 | Such cases can happen, when project uses tools like Tailwind. 15 | 16 | ## Arguments 17 | 18 | 1. `htmlTag` - any valid and React-supported html tag. 19 | 20 | ## Returns 21 | 22 | - React Component, which renders dom element. 23 | 24 | ## Example 25 | 26 | Tailwind users sometimes don't create components, but simply compose classes to apply to regular HTML elements. 27 | 28 | ```ts 29 | import { fromTag, reflect } from '@effector/reflect'; 30 | 31 | const DomInput = fromTag('input'); 32 | 33 | const inputEl = cva('px-3 py-2', { 34 | variants: { 35 | size: { 36 | base: 'text-base', 37 | large: 'text-3xl', 38 | }, 39 | }, 40 | }); 41 | 42 | const NameField = reflect({ 43 | view: DomInput, 44 | bind: { 45 | type: 'email', 46 | value: $value, 47 | className: inputEl({ size: $inputSize }), 48 | }, 49 | }); 50 | ``` 51 | 52 | ### Type inference caveat 53 | 54 | For some reason Typescript type inference works a bit worse, when `fromTag` call is inlined into `reflect` - specifically, callback types are not properly inferred. 55 | Typescript still will check it properly, if you type your callback manually though. 🤷♂️ 56 | 57 | It is recommended to save `fromTag` call result into separate variable instead, since for some reason it works better with TS. 58 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs to Pages 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | workflow_dispatch: 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 15 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 16 | concurrency: 17 | group: "pages" 18 | cancel-in-progress: false 19 | 20 | jobs: 21 | deploy: 22 | environment: 23 | name: github-pages 24 | url: ${{ steps.deployment.outputs.page_url }} 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | 30 | - name: Setup pnpm 31 | uses: pnpm/action-setup@v2 32 | 33 | - name: Use Node.js 18.x 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 18.x 37 | cache: 'pnpm' 38 | cache-dependency-path: '**/pnpm-lock.yaml' 39 | 40 | - name: Setup parent deps 41 | run: 'pnpm install' 42 | 43 | - name: Setup deps 44 | run: 'pnpm install' 45 | working-directory: 'docs/' 46 | 47 | - name: Build 48 | run: 'pnpm build' 49 | working-directory: 'docs/' 50 | 51 | - name: Setup Pages 52 | uses: actions/configure-pages@v3 53 | 54 | - name: Upload artifact 55 | uses: actions/upload-pages-artifact@v3 56 | with: 57 | path: 'docs/out/' 58 | 59 | - name: Deploy to GitHub Pages 60 | id: deployment 61 | uses: actions/deploy-pages@v4 62 | -------------------------------------------------------------------------------- /docs/pages/docs/create-reflect.mdx: -------------------------------------------------------------------------------- 1 | 2 | # `createReflect` 3 | 4 | ```ts 5 | import {createReflect} from '@effector/reflect' 6 | ``` 7 | 8 | Method for creating reflect a view. So you can create a UI kit by views and use a view with a store already. 9 | 10 | ```tsx 11 | // ./ui.tsx 12 | import { createReflect } from '@effector/reflect'; 13 | import React, { ChangeEvent, FC, MouseEvent, useCallback } from 'react'; 14 | 15 | // Input 16 | type InputProps = { 17 | value: string; 18 | onChange: ChangeEvent; 19 | }; 20 | 21 | const Input: FC = ({ value, onChange }) => { 22 | return ; 23 | }; 24 | 25 | export const reflectInput = createReflect(Input); 26 | 27 | // Button 28 | type ButtonProps = { 29 | onClick: MouseEvent; 30 | title?: string; 31 | }; 32 | 33 | const Button: FC = ({ onClick, children, title }) => { 34 | return ( 35 | 36 | {children} 37 | 38 | ); 39 | }; 40 | 41 | export const reflectButton = createReflect(Button); 42 | ``` 43 | 44 | ```tsx 45 | // ./user.tsx 46 | import { createEvent, restore } from 'effector'; 47 | import React, { FC } from 'react'; 48 | 49 | import { reflectButton, reflectInput } from './ui'; 50 | 51 | // Model 52 | const changeName = createEvent(); 53 | const $name = restore(changeName, ''); 54 | 55 | const changeAge = createEvent(); 56 | const $age = restore(changeAge, 0); 57 | 58 | const submit = createEvent(); 59 | 60 | // Components 61 | const Name = reflectInput({ 62 | value: $name, 63 | onChange: (event) => changeName(event.target.value), 64 | }); 65 | 66 | const Age = reflectInput({ 67 | value: $age, 68 | onChange: (event) => changeAge(parsetInt(event.target.value)), 69 | }); 70 | 71 | const Submit = reflectButton({ 72 | onClick: submit, 73 | }); 74 | 75 | export const User: FC = () => { 76 | return ( 77 | 78 | 79 | 80 | Save left 81 | Save right 82 | 83 | ); 84 | }; 85 | ``` 86 | -------------------------------------------------------------------------------- /rollup.config.cjs: -------------------------------------------------------------------------------- 1 | const typescript = require('rollup-plugin-typescript2'); 2 | const babel = require('@rollup/plugin-babel'); 3 | const terser = require('@rollup/plugin-terser'); 4 | const { nodeResolve } = require('@rollup/plugin-node-resolve'); 5 | const commonjs = require('@rollup/plugin-commonjs'); 6 | 7 | const babelConfig = require('./babel.config.json'); 8 | 9 | const plugins = () => [ 10 | typescript({ 11 | tsconfig: './tsconfig.json', 12 | check: false, 13 | }), 14 | babel({ 15 | exclude: 'node_modules/**', 16 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 17 | babelHelpers: 'runtime', 18 | presets: babelConfig.presets, 19 | plugins: babelConfig.plugins, 20 | }), 21 | nodeResolve({ 22 | jsnext: true, 23 | skip: ['effector'], 24 | extensions: ['.js', '.mjs'], 25 | }), 26 | commonjs({ extensions: ['.js', '.mjs'] }), 27 | // terser(), 28 | ]; 29 | 30 | const noSsr = './src/index.ts'; 31 | const scope = './src/scope.ts'; 32 | const external = ['effector', 'effector-react', 'react', 'effector-react/scope']; 33 | 34 | module.exports = [ 35 | { 36 | input: noSsr, 37 | external, 38 | plugins: plugins(), 39 | output: { 40 | file: './dist/index.mjs', 41 | format: 'es', 42 | sourcemap: true, 43 | externalLiveBindings: false, 44 | }, 45 | }, 46 | { 47 | input: scope, 48 | external, 49 | plugins: plugins(), 50 | output: { 51 | file: './dist/scope.mjs', 52 | format: 'es', 53 | sourcemap: true, 54 | externalLiveBindings: false, 55 | }, 56 | }, 57 | { 58 | input: noSsr, 59 | external, 60 | plugins: plugins(), 61 | output: { 62 | file: './dist/index.cjs', 63 | format: 'cjs', 64 | freeze: false, 65 | exports: 'named', 66 | sourcemap: true, 67 | externalLiveBindings: false, 68 | }, 69 | }, 70 | { 71 | input: scope, 72 | external, 73 | plugins: plugins(), 74 | output: { 75 | file: './dist/scope.js', 76 | format: 'cjs', 77 | freeze: false, 78 | exports: 'named', 79 | sourcemap: true, 80 | externalLiveBindings: false, 81 | }, 82 | }, 83 | ]; 84 | 85 | // pnpm why sourcemap-codec 86 | // pnpm why sane 87 | // w3c-hr-time 88 | // source-map-resolve 89 | // resolve-url 90 | // source-map-url 91 | // urix 92 | -------------------------------------------------------------------------------- /type-tests/types-from-tag.ts: -------------------------------------------------------------------------------- 1 | import { fromTag, reflect } from '@effector/reflect'; 2 | import { createEvent, createStore } from 'effector'; 3 | import React from 'react'; 4 | import { expectType } from 'tsd'; 5 | 6 | // fromTag creates a valid component 7 | { 8 | const Input = fromTag('input'); 9 | 10 | expectType< 11 | ( 12 | props: React.PropsWithChildren< 13 | React.ClassAttributes & 14 | React.InputHTMLAttributes 15 | >, 16 | ) => React.ReactNode 17 | >(Input); 18 | } 19 | 20 | // fromTag compoment is allowed in reflect 21 | { 22 | const Input = fromTag('input'); 23 | 24 | const $value = createStore(''); 25 | 26 | const View = reflect({ 27 | view: Input, 28 | bind: { 29 | value: $value, 30 | onChange: (e) => { 31 | const strValue = e.target.value; 32 | 33 | strValue.trim(); 34 | }, 35 | }, 36 | }); 37 | } 38 | 39 | // inline fromTag is supported 40 | { 41 | const $value = createStore(''); 42 | 43 | const handleChange = createEvent(); 44 | 45 | const View = reflect({ 46 | view: fromTag('input'), 47 | bind: { 48 | type: 'text', 49 | value: $value, 50 | /** 51 | * Type inference for inline fromTag is slightly worse, than for non-inline version :( 52 | * 53 | * I don't known why, but in this case `onChange` argument type must be type explicitly, 54 | * type inference doesn't work here 55 | * 56 | * TypeScript won't allow invalid value, 57 | * but also won't infer correct type for us here, like it does with non-inline usage :shrug: 58 | */ 59 | onChange: (e: React.ChangeEvent) => { 60 | handleChange(e.target.value); 61 | }, 62 | }, 63 | }); 64 | } 65 | 66 | // invalid props are not supported 67 | { 68 | const Input = fromTag('input'); 69 | 70 | const $value = createStore({}); 71 | 72 | const View = reflect({ 73 | view: Input, 74 | bind: { 75 | // @ts-expect-error 76 | value: $value, 77 | // @ts-expect-error 78 | onChange: (e: string) => {}, 79 | }, 80 | }); 81 | 82 | const View2 = reflect({ 83 | view: fromTag('input'), 84 | bind: { 85 | // @ts-expect-error 86 | value: $value, 87 | // @ts-expect-error 88 | onChange: 'kek', 89 | }, 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Welcome to @effector/reflect" 3 | --- 4 | 5 | import Link from 'next/link' 6 | 7 | 8 | 9 | @effector/reflect 10 | Easily render React components with everything you love from effector. 11 | 12 | Get started → 13 | 14 | 15 | 63 | 64 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish CI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-to-npm: 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Setup pnpm 15 | uses: pnpm/action-setup@v2 16 | 17 | - name: Use Node.js 18.x 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18.x 21 | cache: 'pnpm' 22 | 23 | - name: Install dependencies 24 | run: pnpm install --frozen-lockfile 25 | 26 | - name: Build 27 | run: pnpm build 28 | 29 | - name: Run tests 30 | run: pnpm test 31 | env: 32 | CI: true 33 | 34 | - name: Extract version 35 | id: version 36 | uses: olegtarasov/get-tag@v2.1 37 | with: 38 | tagRegex: 'v(.*)' 39 | 40 | - name: Set version from release 41 | uses: reedyuk/npm-version@1.1.1 42 | with: 43 | version: ${{ steps.version.outputs.tag }} 44 | package: 'dist/' 45 | git-tag-version: false 46 | 47 | - name: Create NPM config 48 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 49 | env: 50 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | 52 | - name: Check for NEXT tag 53 | id: next 54 | uses: actions-ecosystem/action-regex-match@v2 55 | with: 56 | text: ${{ steps.version.outputs.tag }} 57 | regex: '-next' 58 | 59 | - name: Check for RC tag 60 | id: rc 61 | uses: actions-ecosystem/action-regex-match@v2 62 | with: 63 | text: ${{ steps.version.outputs.tag }} 64 | regex: '-rc' 65 | 66 | - name: Publish @effector/reflect@${{ steps.version.outputs.tag }} with NEXT tag 67 | if: ${{ steps.next.outputs.match != '' }} 68 | working-directory: './dist/' 69 | run: npm publish --tag next 70 | 71 | - name: Publish @effector/reflect@${{ steps.version.outputs.tag }} with RC tag 72 | if: ${{ steps.rc.outputs.match != '' }} 73 | working-directory: './dist/' 74 | run: npm publish --tag rc 75 | 76 | - name: Publish @effector/reflect@${{ steps.version.outputs.tag }} to latest 77 | if: ${{ steps.next.outputs.match == '' && steps.rc.outputs.match == '' }} 78 | working-directory: './dist/' 79 | run: npm publish 80 | -------------------------------------------------------------------------------- /src/core/variant.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'effector'; 2 | import React from 'react'; 3 | 4 | import { reflectFactory } from './reflect'; 5 | import { BindProps, Context, Hooks, UseUnitConifg, View } from './types'; 6 | 7 | const Default = () => null; 8 | 9 | export function variantFactory(context: Context) { 10 | const reflect = reflectFactory(context); 11 | 12 | return function variant< 13 | Props, 14 | Variant extends string, 15 | Bind extends BindProps, 16 | >( 17 | config: 18 | | { 19 | source: Store; 20 | bind?: Bind; 21 | cases: Record>; 22 | hooks?: Hooks; 23 | default?: View; 24 | useUnitConfig?: UseUnitConifg; 25 | } 26 | | { 27 | if: Store; 28 | then: View; 29 | else?: View; 30 | hooks?: Hooks; 31 | bind?: Bind; 32 | useUnitConfig?: UseUnitConifg; 33 | }, 34 | ): (p: Props) => React.ReactNode { 35 | let $case: Store; 36 | let cases: Record>; 37 | let def: View; 38 | 39 | // Shortcut for Store 40 | if ('if' in config) { 41 | $case = config.if; 42 | cases = { 43 | then: config.then, 44 | else: config.else, 45 | } as unknown as Record>; 46 | def = Default; 47 | } 48 | // Full form for Store 49 | else { 50 | $case = config.source; 51 | cases = config.cases; 52 | def = config.default ?? Default; 53 | } 54 | 55 | function View(props: Props) { 56 | const nameOfCaseRaw = context.useUnit($case, config.useUnitConfig); 57 | const nameOfCase = ( 58 | typeof nameOfCaseRaw === 'string' 59 | ? nameOfCaseRaw 60 | : booleanToVariant(nameOfCaseRaw) 61 | ) as Variant; 62 | const Component = cases[nameOfCase] ?? def; 63 | 64 | return React.createElement(Component as any, props as any); 65 | } 66 | 67 | const bind = config.bind ?? ({} as Bind); 68 | 69 | return reflect({ 70 | bind, 71 | view: View, 72 | hooks: config.hooks, 73 | useUnitConfig: config.useUnitConfig, 74 | }) as unknown as (p: Props) => React.ReactNode; 75 | }; 76 | } 77 | 78 | function booleanToVariant(value: boolean): 'then' | 'else' { 79 | return value ? 'then' : 'else'; 80 | } 81 | -------------------------------------------------------------------------------- /docs/pages/docs/variant.mdx: -------------------------------------------------------------------------------- 1 | # `variant` 2 | 3 | ```ts 4 | import { variant } from '@effector/reflect'; 5 | ``` 6 | 7 | ```tsx 8 | const Components = variant({ 9 | source: $typeSelector, 10 | bind: Props, 11 | cases: ComponentVariants, 12 | default: DefaultVariant, 13 | hooks: Hooks, 14 | }); 15 | ``` 16 | 17 | Method allows to change component based on value in `$typeSelector`. Optional `bind` allow to pass props bound to stores or events. 18 | 19 | ## Arguments 20 | 21 | 1. `source` — Store of `string` value. Used to select variant of component to render and bound props to. 22 | 1. `bind` — Optional object of stores, events, and static values that would be bound as props. 23 | 1. `cases` — Object of components, key will be used to match 24 | 1. `default` — Optional component, that would be used if no matched in `cases` 25 | 1. [`hooks`](/docs/hooks) — Optional object `{ mounted, unmounted }` to handle when component is mounted or unmounted. 26 | 1. `useUnitConfig` - Optional configuration object, which is passed directly to the second argument of `useUnit` from `effector-react`. 27 | 28 | ## Example 29 | 30 | When `Field` is rendered it checks for `$fieldType` value, selects the appropriate component from `cases` and bound props to it. 31 | 32 | ```tsx 33 | import { variant } from '@effector/reflect'; 34 | import { DateSelector, Range, TextInput } from '@org/ui-lib'; 35 | import { createEvent, createStore } from 'effector'; 36 | import React from 'react'; 37 | 38 | const $fieldType = createStore<'date' | 'number' | 'string'>('string'); 39 | 40 | const valueChanged = createEvent(); 41 | const $value = createStore(''); 42 | 43 | const Field = variant({ 44 | source: $fieldType, 45 | bind: { onChange: valueChanged, value: $value }, 46 | cases: { 47 | date: DateSelector, 48 | number: Range, 49 | }, 50 | default: TextInput, 51 | }); 52 | ``` 53 | 54 | ## Shorthand for boolean cases 55 | 56 | When you have only two cases, you can use `variant` shorthand. 57 | 58 | ```tsx 59 | const Component = variant({ 60 | if: $isError, 61 | then: ErrorComponent, 62 | else: SuccessComponent, 63 | }); 64 | ``` 65 | 66 | This is equivalent to 67 | 68 | ```tsx 69 | const Component = variant({ 70 | source: $isError.map((isError) => (isError ? 'error' : 'success')), 71 | cases: { 72 | error: ErrorComponent, 73 | success: SuccessComponent, 74 | }, 75 | }); 76 | ``` 77 | 78 | This shorthand supports `bind` and `hooks` fields as well. 79 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # @effector/reflect 2 | 3 | ☄️ Attach effector stores to react components without hooks. 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install @effector/reflect 9 | # or 10 | pnpm add @effector/reflect 11 | ``` 12 | 13 | [Getting started](https://reflect.effector.dev/learn/installation) | [API Docs](https://reflect.effector.dev/docs) 14 | 15 | ## Motivation 16 | 17 | What's the point of reflect? 18 | 19 | It's the API design that, using the classic HOC-like pattern, allows you to write React components with Effector in an efficient and composable way. 20 | 21 | ```tsx 22 | import { reflect, variant } from '@effector/reflect'; 23 | 24 | export function UserForm() { 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | const Name = reflect({ 35 | view: Input, 36 | bind: { 37 | value: model.$name, 38 | onChange: model.nameChanged, 39 | }, 40 | }); 41 | 42 | const LastName = reflect({ 43 | view: Input, 44 | bind: { 45 | value: model.$lastName, 46 | onChange: model.lastNameChanged, 47 | }, 48 | }); 49 | 50 | const SubmitButton = reflect({ 51 | view: Button, 52 | bind: { 53 | type: 'submit', // plain values are allowed too! 54 | disabled: model.$formValid.map((valid) => !valid), 55 | onClick: model.formSubmitted, 56 | }, 57 | }); 58 | ``` 59 | 60 | Here we've separated this component into small parts, which were created in a convenient way using `reflect` operators, which is a very simple description of the `props -> values` mapping, which is easier to read and modify. 61 | 62 | With `@effector/reflect`, our `$formValid` update will only cause the SubmitButton to be re-rendered, and for all other parts of our `` there will literally be **zero** React work. 63 | 64 | To learn more, please read the [full Motivation article](https://reflect.effector.dev/learn/motivation). 65 | 66 | ## Release process 67 | 68 | 1. Check out the [draft release](https://github.com/effector/reflect/releases). 69 | 2. All PRs should have correct labels and useful titles. You can [review available labels here](https://github.com/effector/reflect/blob/master/.github/release-drafter.yml). 70 | 3. Update labels for PRs and titles, next [manually run the release drafter action](https://github.com/effector/reflect/actions/workflows/release-drafter.yml) to regenerate the draft release. 71 | 4. Review the new version and press "Publish" 72 | 5. If required check "Create discussion for this release" 73 | -------------------------------------------------------------------------------- /src/core/list.ts: -------------------------------------------------------------------------------- 1 | import { scopeBind, Store } from 'effector'; 2 | import { useProvidedScope } from 'effector-react'; 3 | import React from 'react'; 4 | 5 | import { reflectFactory } from './reflect'; 6 | import { BindProps, Context, Hooks, UseUnitConifg, View } from './types'; 7 | 8 | export function listFactory(context: Context) { 9 | const reflect = reflectFactory(context); 10 | 11 | return function list< 12 | Item extends Record, 13 | Props, 14 | Bind extends BindProps, 15 | >(config: { 16 | source: Store; 17 | view: View; 18 | bind?: Bind; 19 | mapItem?: { 20 | [K in keyof Props]: (item: Item, index: number) => Props[K]; 21 | }; 22 | getKey?: (item: Item) => React.Key; 23 | hooks?: Hooks; 24 | useUnitConfig?: UseUnitConifg; 25 | }): React.FC { 26 | const ItemView = reflect({ 27 | view: config.view, 28 | bind: config.bind ? config.bind : ({} as Bind), 29 | hooks: config.hooks, 30 | useUnitConfig: config.useUnitConfig, 31 | }); 32 | 33 | const listConfig = { 34 | getKey: config.getKey, 35 | fn: (value: Item, index: number) => { 36 | const scope = useProvidedScope(); 37 | const finalProps = React.useMemo(() => { 38 | const props: any = {}; 39 | 40 | if (config.mapItem) { 41 | forIn(config.mapItem, (prop) => { 42 | const fn = 43 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 44 | config.mapItem![prop]; 45 | const propValue = fn(value, index); 46 | 47 | if (typeof propValue === 'function') { 48 | props[prop] = scopeBind(propValue, { 49 | safe: true, 50 | scope: scope || undefined, 51 | }); 52 | } else { 53 | props[prop] = propValue; 54 | } 55 | }); 56 | } else { 57 | forIn(value, (prop) => { 58 | props[prop] = value[prop]; 59 | }); 60 | } 61 | 62 | return props; 63 | }, [value, index]); 64 | 65 | return React.createElement(ItemView, finalProps); 66 | }, 67 | }; 68 | 69 | return () => context.useList(config.source, listConfig); 70 | }; 71 | } 72 | 73 | function forIn, R extends any>( 74 | target: T, 75 | fn: (_t: keyof T) => R, 76 | ): void { 77 | const hasProp = {}.hasOwnProperty; 78 | for (const prop in target) { 79 | if (hasProp.call(target, prop)) fn(prop); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /docs/pages/learn/testing.mdx: -------------------------------------------------------------------------------- 1 | # SSR and tests via Fork API 2 | 3 | Since [effector-react 22.5.0](https://github.com/effector/effector/releases/tag/effector-react%4022.5.0) it is no longer necessary to use `@effector/reflect/ssr` due to isomorphic nature of `effector-react` hooks after this release, you can just use `@effector/reflect` main imports. 4 | 5 | Just add `Provider` from `effector-react` to your app root, and you are good to go. 6 | 7 | ```tsx 8 | // ./ui.tsx 9 | import React, { ChangeEvent, FC, MouseEvent, useCallback } from 'react'; 10 | 11 | // Input 12 | type InputProps = { 13 | value: string; 14 | onChange: ChangeEvent; 15 | }; 16 | 17 | const Input: FC = ({ value, onChange }) => { 18 | return ; 19 | }; 20 | ``` 21 | 22 | ```tsx 23 | // ./app.tsx 24 | import { reflect } from '@effector/reflect'; 25 | import { createEvent, restore, sample, Scope } from 'effector'; 26 | import { Provider } from 'effector-react'; 27 | import React, { FC } from 'react'; 28 | 29 | import { Input } from './ui'; 30 | 31 | // Model 32 | export const appStarted = createEvent<{ name: string }>(); 33 | 34 | const changeName = createEvent(); 35 | const $name = restore(changeName, ''); 36 | 37 | sample({ 38 | clock: appStarted, 39 | fn: (ctx) => ctx.name, 40 | target: changeName, 41 | }); 42 | 43 | // Component 44 | const Name = reflect({ 45 | view: Input, 46 | bind: { 47 | value: $name, 48 | onChange: (event) => changeName(event.target.value), 49 | }, 50 | }); 51 | 52 | export const App: FC<{ scope: Scope }> = ({ scope }) => { 53 | return ( 54 | 55 | 56 | 57 | ); 58 | }; 59 | ``` 60 | 61 | ```tsx 62 | // ./server.tsx 63 | import { allSettled, fork, serialize } from 'effector'; 64 | 65 | import { App, appStarted } from './app'; 66 | 67 | const render = async (reqCtx) => { 68 | const serverScope = fork(); 69 | 70 | await allSettled(appStarted, { 71 | scope: serverScope, 72 | params: { 73 | name: reqCtx.cookies.name, 74 | }, 75 | }); 76 | 77 | const content = renderToString(); 78 | const data = serialize(scope); 79 | 80 | return ` 81 | 82 | ${content} 83 | 86 | 87 | `; 88 | }; 89 | ``` 90 | 91 | ```tsx 92 | // client.tsx 93 | import { fork } from 'effector'; 94 | import { hydrateRoot } from 'react-dom/client'; 95 | 96 | import { App, appStarted } from './app'; 97 | 98 | const clientScope = fork({ 99 | values: window.__initialState__, 100 | }); 101 | 102 | hydrateRoot(document.body, ); 103 | ``` 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@effector/reflect", 3 | "version": "0.0.0-real-version-will-be-set-on-ci", 4 | "repository": "effector/reflect", 5 | "description": "☄️ Attach effector stores to react components without hooks", 6 | "maintainers": [ 7 | "Sergey Sova (https://sova.dev)", 8 | "e.fedotov " 9 | ], 10 | "license": "MIT", 11 | "publishConfig": { 12 | "access": "public" 13 | }, 14 | "packageManager": "pnpm@10.6.5", 15 | "exports": { 16 | ".": { 17 | "types": "./index.d.ts", 18 | "import": "./index.mjs", 19 | "require": "./index.cjs", 20 | "default": "./index.mjs" 21 | }, 22 | "./scope": { 23 | "types": "./scope.d.ts", 24 | "import": "./scope.mjs", 25 | "require": "./scope.js", 26 | "default": "./scope.mjs" 27 | } 28 | }, 29 | "main": "index.cjs", 30 | "module": "index.mjs", 31 | "typings": "index.d.ts", 32 | "files": [ 33 | "Readme.md", 34 | "core", 35 | "index.d.ts", 36 | "ssr.d.ts", 37 | "scope.d.ts", 38 | "index.cjs", 39 | "index.cjs.map", 40 | "index.mjs", 41 | "index.mjs.map", 42 | "scope.js", 43 | "scope.js.map", 44 | "scope.mjs", 45 | "scope.mjs.map" 46 | ], 47 | "scripts": { 48 | "validate:dist": "node ./validate_dist.mjs", 49 | "test:code": "vitest run ./src", 50 | "test:types": "tsc -p ./type-tests", 51 | "test": "pnpm test:code && pnpm test:types", 52 | "build": "pnpm clear-build && node ./build.mjs", 53 | "clear-build": "rm -rf dist", 54 | "prepublishOnly": "pnpm build", 55 | "prepare": "husky install" 56 | }, 57 | "devDependencies": { 58 | "@arethetypeswrong/cli": "^0.13.10", 59 | "@babel/core": "^7.26.10", 60 | "@babel/plugin-transform-runtime": "^7.26.10", 61 | "@babel/preset-env": "^7.26.9", 62 | "@babel/preset-react": "^7.26.3", 63 | "@babel/preset-typescript": "^7.26.0", 64 | "@mantine/core": "^7.17.2", 65 | "@rollup/plugin-babel": "^6.0.4", 66 | "@rollup/plugin-commonjs": "^24.1.0", 67 | "@rollup/plugin-node-resolve": "^15.3.1", 68 | "@rollup/plugin-terser": "^0.4.4", 69 | "@testing-library/dom": "^10.4.0", 70 | "@testing-library/react": "^16.2.0", 71 | "@testing-library/user-event": "^14.6.1", 72 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 73 | "@types/jest": "^26.0.24", 74 | "@types/react": "^19.0.12", 75 | "@types/react-dom": "^19.0.4", 76 | "babel-jest": "^29.7.0", 77 | "babel-plugin-module-resolver": "^4.1.0", 78 | "effector": "^23.3.0", 79 | "effector-react": "^23.3.0", 80 | "eslint": "^8.57.1", 81 | "eslint-kit": "^6.12.0", 82 | "fs-extra": "^9.1.0", 83 | "husky": "^8.0.3", 84 | "jsdom": "^23.2.0", 85 | "lint-staged": "^13.3.0", 86 | "prettier": "^2.8.8", 87 | "pretty-ms": "^8.0.0", 88 | "publint": "^0.2.12", 89 | "react": "^19.0.0", 90 | "react-dom": "^19.0.0", 91 | "rollup": "^3.29.5", 92 | "rollup-plugin-typescript2": "^0.34.1", 93 | "svelte": "^3.59.2", 94 | "tsd": "^0.19.1", 95 | "typescript": "^5.8.2", 96 | "uglify-js": "^3.19.3", 97 | "vite-tsconfig-paths": "^4.3.2", 98 | "vitest": "^1.6.1", 99 | "zx": "^7.2.3" 100 | }, 101 | "peerDependencies": { 102 | "effector": "^23.1.0", 103 | "effector-react": "^23.1.0", 104 | "react": ">=16.8.0 <20.0.0" 105 | }, 106 | "dependencies": { 107 | "@vitejs/plugin-react": "^4.3.4" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /docs/pages/docs/list.mdx: -------------------------------------------------------------------------------- 1 | # `list` 2 | 3 | ```ts 4 | import { list } from '@effector/reflect'; 5 | ``` 6 | 7 | ```tsx 8 | const Items: React.FC = list({ 9 | view: React.FC, 10 | source: Store, 11 | bind: { 12 | // regular reflect's bind, for list item view 13 | }, 14 | hooks: { 15 | // regular reflect's hooks, for list item view 16 | }, 17 | mapItem: { 18 | propName: (item: Item, index: number) => propValue, // maps array store item to View props 19 | }, 20 | getKey: (item: Item) => React.Key, // optional, will use index by default 21 | }); 22 | ``` 23 | 24 | Method creates component, which renders list of `view` components based on items in array in `source` store, each item content's will be mapped to View props by `mapItem` rules. On changes to `source` store, rendered list will be updated too 25 | 26 | ## Arguments 27 | 28 | 1. `source` — Store of `Item[]` value. 29 | 1. `view` — A react component, will be used to render list items 30 | 1. `mapItem` — Object `{ propName: (Item, index) => propValue }` that defines rules, by which every `Item` will be mapped to props of each rendered list item. 31 | 1. `bind` — Optional object of stores, events, and static values that will be bound as props to every list item. 32 | 1. [`hooks`](/docs/hooks) — Optional object `{ mounted, unmounted }` to handle when any list item component is mounted or unmounted. 33 | 1. `getKey` - Optional function `(item: Item) => React.Key` to set key for every item in the list to help React with effecient rerenders. If not provided, index is used. See [`effector-react`](https://effector.dev/docs/api/effector-react/useList) docs for more details. 34 | 1. `useUnitConfig` - Optional configuration object, which is passed directly to the second argument of `useUnit` from `effector-react`. 35 | 36 | ## Returns 37 | 38 | - A react component that renders a list of `view` components based on items of array in `source` store. Every `view` component props are bound to array item contents by the rules in `mapItem`, and to stores and events in `bind`, like with regular `reflect` 39 | 40 | ## Example 41 | 42 | ```tsx 43 | import { list } from '@effector/reflect'; 44 | import { createEvent, createStore } from 'effector'; 45 | import React from 'react'; 46 | 47 | const $color = createStore('red'); 48 | 49 | const $users = createStore([ 50 | { id: 1, name: 'Yung' }, 51 | { id: 2, name: 'Lean' }, 52 | { id: 3, name: 'Kyoto' }, 53 | { id: 4, name: 'Sesh' }, 54 | ]); 55 | 56 | const Item = ({ id, name, color }) => { 57 | return ( 58 | 59 | {id} - {name} 60 | 61 | ); 62 | }; 63 | 64 | const Items = list({ 65 | view: Item, 66 | source: $users, 67 | bind: { 68 | color: $color, 69 | }, 70 | mapItem: { 71 | id: (user) => user.id, 72 | name: (user) => user.name, 73 | }, 74 | getKey: (user) => `${user.id}${user.name}`, 75 | }); 76 | 77 | 78 | 79 | ; 80 | ``` 81 | 82 | ### Fork API auto-compatibility 83 | 84 | **This feature is available since `9.0.0` release of Reflect.** 85 | 86 | The `list` operator also supports automatic Fork API support for callbacks **created** from `mapItem`: 87 | 88 | ```ts 89 | const userChanged = createEvent(); 90 | const Items = list({ 91 | view: Item, 92 | source: $users, 93 | bind: { 94 | color: $color, 95 | }, 96 | mapItem: { 97 | id: (user) => user.id, 98 | name: (user) => user.name, 99 | onChange: (user) => (nextUser) => { 100 | userChanged({ oldUser: user, nextUser }); 101 | }, 102 | }, 103 | getKey: (user) => `${user.id}${user.name}`, 104 | }); 105 | ``` 106 | 107 | ☝️ Notice, how `mapItem.onChange` creates a event handler callback for every rendered item. Those callbacks are also binded to Scope, if it is provided. 108 | For more details read the "Fork API auto-compatibility" part of the `reflect` operator documentation. 109 | -------------------------------------------------------------------------------- /src/core/reflect.ts: -------------------------------------------------------------------------------- 1 | import { Effect, Event, is, scopeBind, Store } from 'effector'; 2 | import { useProvidedScope } from 'effector-react'; 3 | import React, { PropsWithoutRef, RefAttributes } from 'react'; 4 | 5 | import { BindProps, Context, Hooks, UseUnitConifg, View } from './types'; 6 | 7 | export interface ReflectConfig> { 8 | view: View; 9 | bind: Bind; 10 | hooks?: Hooks; 11 | useUnitConfig?: UseUnitConifg; 12 | } 13 | 14 | export function reflectCreateFactory(context: Context) { 15 | const reflect = reflectFactory(context); 16 | 17 | return function createReflect(view: View) { 18 | return = BindProps>( 19 | bind: Bind, 20 | params?: Pick, 'hooks' | 'useUnitConfig'>, 21 | ) => reflect({ view, bind, ...params }); 22 | }; 23 | } 24 | 25 | export function reflectFactory(context: Context) { 26 | return function reflect = BindProps>( 27 | config: ReflectConfig, 28 | ): React.ExoticComponent & RefAttributes> { 29 | const { stores, events, data, functions } = sortProps(config.bind); 30 | const hooks = sortProps(config.hooks || {}); 31 | 32 | return React.forwardRef((props: Props, ref) => { 33 | const storeProps = context.useUnit(stores, config.useUnitConfig); 34 | const eventsProps = context.useUnit(events as any, config.useUnitConfig); 35 | const functionProps = useBoundFunctions(functions); 36 | 37 | const finalProps: any = {}; 38 | 39 | if (ref) { 40 | finalProps.ref = ref; 41 | } 42 | 43 | const elementProps: Props = Object.assign( 44 | finalProps, 45 | storeProps, 46 | eventsProps, 47 | data, 48 | functionProps, 49 | props, 50 | ); 51 | 52 | const eventsHooks = context.useUnit(hooks.events as any, config.useUnitConfig); 53 | const functionsHooks = useBoundFunctions(hooks.functions); 54 | 55 | React.useEffect(() => { 56 | const hooks: Hooks = Object.assign({}, functionsHooks, eventsHooks); 57 | 58 | if (hooks.mounted) { 59 | hooks.mounted(elementProps); 60 | } 61 | 62 | return () => { 63 | if (hooks.unmounted) { 64 | hooks.unmounted(elementProps); 65 | } 66 | }; 67 | }, [eventsHooks, functionsHooks]); 68 | 69 | return React.createElement(config.view as any, elementProps as any); 70 | }); 71 | }; 72 | } 73 | 74 | function sortProps(props: T) { 75 | type GenericEvent = Event | Effect; 76 | 77 | const events: Record = {}; 78 | const stores: Record> = {}; 79 | const data: Record = {}; 80 | const functions: Record = {}; 81 | 82 | for (const key in props) { 83 | const value = props[key]; 84 | 85 | if (is.event(value) || is.effect(value)) { 86 | events[key] = value; 87 | } else if (is.store(value)) { 88 | stores[key] = value; 89 | } else if (typeof value === 'function') { 90 | functions[key] = value; 91 | } else { 92 | data[key] = value; 93 | } 94 | } 95 | 96 | return { events, stores, data, functions }; 97 | } 98 | 99 | function useBoundFunctions(functions: Record) { 100 | const scope = useProvidedScope(); 101 | 102 | return React.useMemo(() => { 103 | const boundFunctions: Record = {}; 104 | 105 | for (const key in functions) { 106 | const fn = functions[key]; 107 | 108 | boundFunctions[key] = scopeBind(fn, { scope: scope || undefined, safe: true }); 109 | } 110 | 111 | return boundFunctions; 112 | }, [scope, functions]); 113 | } 114 | -------------------------------------------------------------------------------- /docs/pages/docs/reflect.mdx: -------------------------------------------------------------------------------- 1 | # `reflect` 2 | 3 | ```ts 4 | import { reflect } from '@effector/reflect'; 5 | ``` 6 | 7 | ```tsx 8 | const Component = reflect({ 9 | view: SourceComponent, 10 | bind: Props, 11 | hooks: Hooks, 12 | }); 13 | ``` 14 | 15 | Static method to create a component bound to effector stores and events as stores. 16 | 17 | ## Arguments 18 | 19 | 1. `view` — A react component that should be used to bind to 20 | 1. `bind` — Object of effector stores, events or any value 21 | 1. [`hooks`](/docs/hooks) — Optional object `{ mounted, unmounted }` to handle when component is mounted or unmounted. 22 | 1. `useUnitConfig` - Optional configuration object, which is passed directly to the second argument of `useUnit` from `effector-react`. 23 | 24 | ## Returns 25 | 26 | - A react component with bound values from stores and events. 27 | 28 | ## Example 29 | 30 | ```tsx 31 | // ./user.tsx 32 | import { reflect } from '@effector/reflect'; 33 | import { createEvent, restore } from 'effector'; 34 | import React, { ChangeEvent, FC } from 'react'; 35 | 36 | // Base components 37 | type InputProps = { 38 | value: string; 39 | onChange: ChangeEvent; 40 | placeholder?: string; 41 | }; 42 | 43 | const Input: FC = ({ value, onChange, placeholder }) => { 44 | return ; 45 | }; 46 | 47 | // Model 48 | const changeName = createEvent(); 49 | const $name = restore(changeName, ''); 50 | 51 | const changeAge = createEvent(); 52 | const $age = restore(changeAge, 0); 53 | 54 | 55 | // Components 56 | const Name = reflect({ 57 | view: Input, 58 | bind: { 59 | value: $name, 60 | placeholder: 'Name', 61 | onChange: (event) => changeName(event.currentTarget.value), 62 | }, 63 | }); 64 | 65 | const Age = reflect({ 66 | view: Input, 67 | bind: { 68 | value: $age, 69 | placeholder: 'Age', 70 | onChange: (event) => changeAge(parseInt(event.currentTarget.value)), 71 | }, 72 | }); 73 | 74 | export const User: FC = () => { 75 | return ( 76 | 77 | 78 | 79 | 80 | ); 81 | }; 82 | ``` 83 | 84 | ### Fork API auto-compatibility 85 | 86 | **This feature is available since `9.0.0` release of Reflect.** 87 | 88 | The [Fork API](https://effector.dev/en/api/effector/fork/) - is a feature of Effector, which allows to seamlessly create virtualized instances of the application's state and logic called [Scope](https://effector.dev/en/api/effector/scope/)'s. 89 | In React to render an App in specific `Scope` a [Provider](https://effector.dev/en/api/effector-react/provider/) component should be used. 90 | 91 | When an external system (like React) calls an Effector event and Fork API is used - target `Scope` should be provided before call via `allSettled` or `scopeBind` API. 92 | 93 | The `reflect` operator does it for you under the hood, so you can provide arbitary callbacks into `bind`: 94 | 95 | ```tsx 96 | const Age = reflect({ 97 | view: Input, 98 | bind: { 99 | value: $age, 100 | placeholder: 'Age', 101 | onChange: (event) => { 102 | changeAge(parseInt(event.currentTarget.value)); 103 | }, 104 | }, 105 | }); 106 | ``` 107 | 108 | ☝️ This feature works for `bind` field in all of Reflect operators. 109 | 110 | In most cases your callbacks will be synchronous and you will not need to do anything besides using [Provider](https://effector.dev/en/api/effector-react/provider/) to set the Scope for the whole React tree. 111 | 112 | #### Special case: Asynchronous callbacks 113 | 114 | Asynchronous callbacks are also allowed, but those should follow the rules of [Imperative Effect calls with Scope](https://effector.dev/en/api/effector/scope/) to be compatible. 115 | 116 | ## TypeScript and Polymorphic Types Caveat 117 | 118 | Generally, reflect handles polymorphic props well. 119 | However, in some implementations, such as [Mantine UI](https://mantine.dev/guides/polymorphic/), it may not work as expected. 120 | 121 | In such cases, it is recommended to explicitly narrow the component type. For example: 122 | 123 | ```ts 124 | const ReflectedMantineButton = reflect({ 125 | view: MantineButton<'button'>, // <- notice explicit component type in the "<...>" brackets 126 | bind: { 127 | children: 'foo', 128 | onClick: (e) => { 129 | clicked(e.clientX); 130 | }, 131 | }, 132 | }); 133 | ``` 134 | 135 | -------------------------------------------------------------------------------- /src/ssr/create-reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import { createReflect } from '@effector/reflect/scope'; 2 | import { act, render } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { allSettled, createDomain, fork, restore } from 'effector'; 5 | import { Provider } from 'effector-react/scope'; 6 | import React, { ChangeEvent, FC, InputHTMLAttributes } from 'react'; 7 | 8 | // Example1 (InputCustom) 9 | const InputCustom: FC<{ 10 | value: string | number | string[]; 11 | onChange(value: string): void; 12 | testId: string; 13 | placeholder?: string; 14 | }> = (props) => { 15 | return ( 16 | props.onChange(event.currentTarget.value)} 21 | /> 22 | ); 23 | }; 24 | 25 | const inputCustom = createReflect(InputCustom); 26 | 27 | test('InputCustom', async () => { 28 | const app = createDomain(); 29 | 30 | const change = app.createEvent(); 31 | const $name = restore(change, ''); 32 | 33 | const Name = inputCustom({ value: $name, onChange: change }); 34 | 35 | const scope = fork(app); 36 | 37 | expect(scope.getState($name)).toBe(''); 38 | await allSettled(change, { scope, params: 'Bob' }); 39 | expect(scope.getState($name)).toBe('Bob'); 40 | 41 | const container = render( 42 | 43 | 44 | , 45 | ); 46 | 47 | const inputName = container.container.firstChild as HTMLInputElement; 48 | expect(inputName.value).toBe('Bob'); 49 | }); 50 | 51 | test('InputCustom [replace value]', async () => { 52 | const app = createDomain(); 53 | 54 | const change = app.createEvent(); 55 | const $name = app.createStore(''); 56 | 57 | $name.on(change, (_, next) => next); 58 | 59 | const Name = inputCustom({ name: $name, onChange: change }); 60 | 61 | const scope = fork(app); 62 | 63 | expect(scope.getState($name)).toBe(''); 64 | await allSettled(change, { scope, params: 'Bob' }); 65 | expect(scope.getState($name)).toBe('Bob'); 66 | 67 | const container = render( 68 | 69 | 70 | , 71 | ); 72 | 73 | const inputName = container.container.firstChild as HTMLInputElement; 74 | expect(inputName.value).toBe('Alise'); 75 | }); 76 | 77 | // Example 2 (InputBase) 78 | const InputBase: FC> = (props) => { 79 | return ; 80 | }; 81 | 82 | const inputBase = createReflect(InputBase); 83 | 84 | test('InputBase', async () => { 85 | const app = createDomain(); 86 | 87 | const changeName = app.createEvent(); 88 | const $name = restore(changeName, ''); 89 | 90 | const inputChanged = (event: ChangeEvent) => { 91 | return event.currentTarget.value; 92 | }; 93 | 94 | const Name = inputBase({ 95 | value: $name, 96 | onChange: changeName.prepend(inputChanged), 97 | }); 98 | 99 | const changeAge = app.createEvent(); 100 | const $age = restore(changeAge, 0); 101 | 102 | const Age = inputBase({ 103 | value: $age, 104 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 105 | }); 106 | 107 | const scope = fork(app); 108 | 109 | expect(scope.getState($name)).toBe(''); 110 | await allSettled(changeName, { scope, params: 'Bob' }); 111 | expect(scope.getState($name)).toBe('Bob'); 112 | 113 | expect(scope.getState($age)).toBe(0); 114 | await allSettled(changeAge, { scope, params: 25 }); 115 | expect(scope.getState($age)).toBe(25); 116 | 117 | const container = render( 118 | 119 | 120 | 121 | , 122 | ); 123 | 124 | const inputName = container.getByTestId('name') as HTMLInputElement; 125 | expect(inputName.value).toBe('Bob'); 126 | 127 | const inputAge = container.getByTestId('age') as HTMLInputElement; 128 | expect(inputAge.value).toBe('25'); 129 | }); 130 | 131 | test('with ssr for client', async () => { 132 | const app = createDomain(); 133 | 134 | const changeName = app.createEvent(); 135 | const $name = restore(changeName, ''); 136 | 137 | const Name = inputBase({ 138 | value: $name, 139 | onChange: changeName.prepend((event) => event.currentTarget.value), 140 | }); 141 | 142 | const scope = fork(app); 143 | 144 | const container = render( 145 | 146 | 147 | , 148 | ); 149 | 150 | expect($name.getState()).toBe(''); 151 | await userEvent.type(container.getByTestId('name'), 'Bob'); 152 | expect(scope.getState($name)).toBe('Bob'); 153 | 154 | const inputName = container.getByTestId('name') as HTMLInputElement; 155 | expect(inputName.value).toBe('Bob'); 156 | }); 157 | -------------------------------------------------------------------------------- /docs/pages/learn/motivation.mdx: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | What's the point of reflect? 4 | 5 | It's the API design that, using the classic HOC pattern, allows you to write React components with Effector in an efficient and composable way. 6 | 7 | ## The usual way 8 | 9 | Let's take a look at typical example of hooks usage: 10 | 11 | ```tsx 12 | import { useUnit } from 'effector-react'; 13 | import { Button, ErrorMessage, FormContainer, Input } from 'ui-lib'; 14 | 15 | import * as model from './form-model'; 16 | 17 | export function UserForm() { 18 | const { 19 | formValid, 20 | name, 21 | nameChanged, 22 | lastName, 23 | lastNameChanged, 24 | formSubmitted, 25 | error, 26 | } = useUnit({ 27 | formValid: model.$formValid, 28 | name: model.$name, 29 | nameChanged: model.nameChanged, 30 | lastName: model.lastNameChanged, 31 | formSubmitted: model.formSubmitted, 32 | error: model.$error, 33 | }); 34 | 35 | return ( 36 | 37 | 38 | 39 | {error && } 40 | 41 | 42 | ); 43 | } 44 | ``` 45 | 46 | Here we have a fairly typical structure: the user form is represented by one big component tree, which takes all its subscriptions at the top level, and then the data is provided down the tree via props. 47 | 48 | As you can see, the disadvantage of this approach is that any update to `$formValid` or `$name` will cause a full rendering of that component tree, even though each of those stores is only needed for one specific input or submit button at the bottom. This means that React will have to do more work on diffing to create the update in the DOM. 49 | 50 | This can be fixed by moving the subscriptions further down the component tree by creating separate components, as done here 51 | 52 | ```tsx 53 | function UserFormSubmitButton() { 54 | const { formValid, formSubmitted } = useUnit({ 55 | formValid: model.$formValid, 56 | formSubmitted: model.formSubmitted, 57 | }); 58 | 59 | return ; 60 | } 61 | ``` 62 | 63 | However, it's very often not very convenient to create a separate component with a separate subscription, because it produces more code that's a little harder to read and modify. Since it's essentially mapping store values to props - it's easier to do it just once at the very top. 64 | 65 | Also, in most cases it's not a big problem, since React is pretty fast at diffing. But as the application gets bigger, there are more and more of these small performance problems in the code, and more and more of them combine into bigger performance issues. 66 | 67 | ## Reflect's way 68 | 69 | That's where reflect comes to the rescue: 70 | 71 | ```tsx 72 | import { reflect, variant } from '@effector/reflect'; 73 | 74 | export function UserForm() { 75 | return ( 76 | 77 | 78 | 79 | 80 | 81 | 82 | ); 83 | } 84 | 85 | const Name = reflect({ 86 | view: Input, 87 | bind: { 88 | value: model.$name, 89 | onChange: model.nameChanged, 90 | }, 91 | }); 92 | 93 | const LastName = reflect({ 94 | view: Input, 95 | bind: { 96 | value: model.$lastName, 97 | onChange: model.lastNameChanged, 98 | }, 99 | }); 100 | 101 | const Error = variant({ 102 | if: model.$error, 103 | then: reflect({ 104 | view: ErrorMessage, 105 | bind: { 106 | text: model.$error, 107 | }, 108 | }), 109 | }); 110 | 111 | const SubmitButton = reflect({ 112 | view: Button, 113 | bind: { 114 | type: 'submit', // plain values are allowed too! 115 | disabled: model.$formValid.map((valid) => !valid), 116 | onClick: model.formSubmitted, 117 | }, 118 | }); 119 | ``` 120 | 121 | Here we've separated this component into small parts, which were created in a convenient way using `reflect` operators, which is a very simple description of the `props -> values` mapping, which is easier to read and modify. 122 | 123 | Also, these components are combined into one pure `UserForm` component, which handles only the component structure and has no subscriptions to external sources. 124 | 125 | In this way, we have achieved a kind of _"fine-grained"_ subscriptions - each component listens only to the relevant stores, and each update will cause only small individual parts of the component tree to be rendered. 126 | 127 | React handles such updates much better than updating one big tree, because it requires it to check and compare many more things than is necessary in this case. You can learn more about React's rendering behavior [from this awesome article](https://blog.isquaredsoftware.com/2020/05/blogged-answers-a-mostly-complete-guide-to-react-rendering-behavior/) 128 | 129 | With `@effector/reflect`, our `$formValid` update will only cause the SubmitButton to be re-rendered, and for all other parts of our `` there will literally be **zero** React work. 130 | -------------------------------------------------------------------------------- /type-tests/types-list.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { list } from '@effector/reflect'; 3 | import { Button } from '@mantine/core'; 4 | import { createEvent, createStore } from 'effector'; 5 | import React from 'react'; 6 | import { expectType } from 'tsd'; 7 | 8 | // basic usage of list 9 | { 10 | const Item: React.FC<{ 11 | id: number; 12 | value: string; 13 | onChange: (update: [id: string, newValue: string]) => void; 14 | }> = () => null; 15 | const changed = createEvent<[id: string, newValue: string]>(); 16 | const $items = createStore<{ id: number; value: string }[]>([]); 17 | 18 | const List = list({ 19 | source: $items, 20 | view: Item, 21 | bind: { 22 | onChange: changed, 23 | }, 24 | mapItem: { 25 | id: (item) => item.id, 26 | value: (item) => item.value, 27 | }, 28 | getKey: (item) => item.id, 29 | }); 30 | 31 | expectType(List); 32 | } 33 | 34 | // list has default option for getKey, so this should not be required 35 | { 36 | const Item: React.FC<{ 37 | id: number; 38 | value: string; 39 | onChange: (update: [id: string, newValue: string]) => void; 40 | }> = () => null; 41 | const changed = createEvent<[id: string, newValue: string]>(); 42 | const $items = createStore<{ id: number; value: string }[]>([]); 43 | 44 | const List = list({ 45 | source: $items, 46 | view: Item, 47 | bind: { 48 | onChange: changed, 49 | }, 50 | mapItem: { 51 | id: (item) => item.id, 52 | value: (item) => item.value, 53 | }, 54 | }); 55 | 56 | expectType(List); 57 | } 58 | 59 | // list highlightes missing props for items view 60 | // since missing props cannot be added at react later (contrary to reflect) 61 | { 62 | const Item: React.FC<{ 63 | id: number; 64 | value: string; 65 | onChange: (update: [id: string, newValue: string]) => void; 66 | }> = () => null; 67 | const $items = createStore<{ id: number; value: string }[]>([]); 68 | 69 | const List = list({ 70 | source: $items, 71 | view: Item, 72 | bind: {}, 73 | // @ts-expect-error 74 | mapItem: { 75 | id: (item) => item.id, 76 | value: (item) => item.value, 77 | }, 78 | }); 79 | 80 | expectType(List); 81 | } 82 | 83 | // list allows optional bind 84 | { 85 | const Item: React.FC<{ 86 | id: number; 87 | value: string; 88 | onChange: (update: [id: string, newValue: string]) => void; 89 | }> = () => null; 90 | const $items = createStore<{ id: number; value: string }[]>([]); 91 | 92 | const List = list({ 93 | source: $items, 94 | view: Item, 95 | mapItem: { 96 | id: (item) => item.id, 97 | value: (item) => item.value, 98 | onChange: (_item) => (_params) => {}, 99 | }, 100 | }); 101 | 102 | expectType(List); 103 | } 104 | 105 | // list allows optional mapItem 106 | { 107 | const Item: React.FC<{ 108 | id: number; 109 | value: string; 110 | common: string; 111 | }> = () => null; 112 | const $common = createStore('common prop'); 113 | const $items = createStore<{ id: number; value: string }[]>([]); 114 | 115 | const List = list({ 116 | source: $items, 117 | bind: { 118 | common: $common, 119 | }, 120 | view: Item, 121 | }); 122 | 123 | expectType(List); 124 | } 125 | 126 | // list does not allow to set prop in mapItem, if it is already set in bind 127 | { 128 | const Item: React.FC<{ 129 | id: number; 130 | value: string; 131 | common: string; 132 | }> = () => null; 133 | const $common = createStore('common prop'); 134 | const $items = createStore<{ id: number; value: string }[]>([]); 135 | 136 | const List = list({ 137 | source: $items, 138 | bind: { 139 | common: $common, 140 | }, 141 | mapItem: { 142 | // @ts-expect-error 143 | common: () => 'common prop', 144 | }, 145 | view: Item, 146 | }); 147 | 148 | expectType(List); 149 | } 150 | 151 | // list allows not to set both `bind` and `mapItem` if source type matches with props 152 | { 153 | const Item: React.FC<{ 154 | id: number; 155 | value: string; 156 | }> = () => null; 157 | const $items = createStore<{ id: number; value: string }[]>([]); 158 | 159 | const List = list({ 160 | source: $items, 161 | view: Item, 162 | }); 163 | 164 | expectType(List); 165 | } 166 | 167 | // list doesn't allow not to set both `bind` and `mapItem` if source type doesn't matches with props 168 | { 169 | const Item: React.FC<{ 170 | id: number; 171 | value: string; 172 | }> = () => null; 173 | const $items = createStore<{ biba: string; boba: string }[]>([]); 174 | 175 | // @ts-expect-error 176 | const List = list({ 177 | source: $items, 178 | view: Item, 179 | }); 180 | 181 | expectType(List); 182 | } 183 | 184 | // Edge-case: Mantine Button with weird polymorphic factory 185 | { 186 | const clicked = createEvent(); 187 | 188 | const List = list({ 189 | source: createStore([]), 190 | view: Button<'button'>, 191 | mapItem: { 192 | children: (item) => item, 193 | onClick: (_item) => (e) => clicked(e.clientX), 194 | }, 195 | }); 196 | 197 | expectType(List); 198 | } 199 | -------------------------------------------------------------------------------- /src/ssr/variant.test.tsx: -------------------------------------------------------------------------------- 1 | import { variant } from '@effector/reflect/scope'; 2 | import { act, render } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { allSettled, createDomain, fork, restore } from 'effector'; 5 | import { Provider } from 'effector-react/scope'; 6 | import React from 'react'; 7 | 8 | test('matches first', async () => { 9 | const app = createDomain(); 10 | const changeValue = app.createEvent(); 11 | const changeType = app.createEvent<'first' | 'second' | 'third'>(); 12 | const $value = restore(changeValue, ''); 13 | const $type = restore(changeType, 'first'); 14 | 15 | const Input = variant({ 16 | source: $type, 17 | bind: { value: $value, onChange: changeValue }, 18 | cases: { 19 | first: InputCustom, 20 | second: InputCustom2, 21 | third: InputCustom3, 22 | }, 23 | }); 24 | 25 | const scope = fork(app); 26 | 27 | const container = render( 28 | 29 | 30 | , 31 | ); 32 | 33 | await userEvent.type(container.getByTestId('check'), 'ForExample'); 34 | expect(scope.getState($value)).toBe('ForExample'); 35 | 36 | const input = container.container.firstChild as HTMLInputElement; 37 | expect(input.className).toBe('first'); 38 | 39 | await act(async () => { 40 | await allSettled(changeType, { scope, params: 'second' }); 41 | }); 42 | 43 | expect(scope.getState($value)).toBe('ForExample'); 44 | const updatedInput = container.container.firstChild as HTMLInputElement; 45 | expect(updatedInput.className).toBe('second'); 46 | }); 47 | 48 | test.todo('rerenders only once after change source'); 49 | test.todo('rerenders only once on type'); 50 | test.todo('renders default if no match'); 51 | test.todo('works on nested matches'); 52 | 53 | test('hooks works once on mount', async () => { 54 | const app = createDomain(); 55 | const changeType = app.createEvent<'first' | 'second' | 'third'>(); 56 | const $type = restore(changeType, 'first'); 57 | const mounted = app.createEvent(); 58 | const fn = vi.fn(); 59 | mounted.watch(fn); 60 | 61 | const Input = variant({ 62 | source: $type, 63 | bind: { value: '', onChange: Function }, 64 | hooks: { mounted }, 65 | cases: { 66 | first: InputCustom, 67 | second: InputCustom2, 68 | third: InputCustom3, 69 | }, 70 | }); 71 | 72 | const scope = fork(app); 73 | 74 | expect(fn).not.toBeCalled(); 75 | 76 | render( 77 | 78 | 79 | , 80 | ); 81 | expect(fn).toBeCalledTimes(1); 82 | 83 | await act(async () => { 84 | await allSettled(changeType, { scope, params: 'second' }); 85 | }); 86 | expect(fn).toBeCalledTimes(1); 87 | }); 88 | 89 | test('hooks works once on unmount', async () => { 90 | const app = createDomain(); 91 | const changeType = app.createEvent<'first' | 'second' | 'third'>(); 92 | const $type = restore(changeType, 'first'); 93 | const unmounted = app.createEvent(); 94 | const fn = vi.fn(); 95 | unmounted.watch(fn); 96 | const setVisible = app.createEvent(); 97 | const $visible = restore(setVisible, true); 98 | 99 | const Input = variant({ 100 | source: $type, 101 | bind: { value: '', onChange: Function }, 102 | hooks: { unmounted }, 103 | cases: { 104 | first: InputCustom, 105 | second: InputCustom2, 106 | third: InputCustom3, 107 | }, 108 | }); 109 | 110 | const Component = variant({ 111 | source: $visible.map(String), 112 | cases: { 113 | true: Input, 114 | }, 115 | }); 116 | 117 | const scope = fork(app); 118 | 119 | expect(fn).not.toBeCalled(); 120 | 121 | render( 122 | 123 | 124 | , 125 | ); 126 | expect(fn).not.toBeCalled(); 127 | 128 | await act(async () => { 129 | await allSettled(setVisible, { scope, params: false }); 130 | }); 131 | expect(fn).toBeCalledTimes(1); 132 | }); 133 | 134 | test('hooks works on remount', async () => { 135 | const app = createDomain(); 136 | const changeType = app.createEvent<'first' | 'second' | 'third'>(); 137 | const $type = restore(changeType, 'first'); 138 | 139 | const unmounted = app.createEvent(); 140 | const onUnmount = vi.fn(); 141 | unmounted.watch(onUnmount); 142 | const mounted = app.createEvent(); 143 | const onMount = vi.fn(); 144 | mounted.watch(onMount); 145 | 146 | const setVisible = app.createEvent(); 147 | const $visible = restore(setVisible, true); 148 | 149 | const Input = variant({ 150 | source: $type, 151 | bind: { value: '', onChange: Function }, 152 | hooks: { unmounted, mounted }, 153 | cases: { 154 | first: InputCustom, 155 | second: InputCustom2, 156 | third: InputCustom3, 157 | }, 158 | }); 159 | 160 | const Component = variant({ 161 | source: $visible.map(String), 162 | cases: { 163 | true: Input, 164 | }, 165 | }); 166 | 167 | expect(onMount).not.toBeCalled(); 168 | expect(onUnmount).not.toBeCalled(); 169 | 170 | const scope = fork(app); 171 | 172 | render( 173 | 174 | 175 | , 176 | ); 177 | expect(onMount).toBeCalledTimes(1); 178 | expect(onUnmount).not.toBeCalled(); 179 | 180 | await act(async () => { 181 | await allSettled(setVisible, { scope, params: false }); 182 | }); 183 | expect(onUnmount).toBeCalledTimes(1); 184 | 185 | await act(async () => { 186 | await allSettled(setVisible, { scope, params: true }); 187 | }); 188 | expect(onMount).toBeCalledTimes(2); 189 | expect(onUnmount).toBeCalledTimes(1); 190 | }); 191 | 192 | function InputCustom(props: { 193 | value: string | number | string[]; 194 | onChange(value: string): void; 195 | testId: string; 196 | placeholder?: string; 197 | }) { 198 | return ( 199 | props.onChange(event.currentTarget.value)} 205 | /> 206 | ); 207 | } 208 | 209 | function InputCustom2(props: { 210 | value: string | number | string[]; 211 | onChange(value: string): void; 212 | testId: string; 213 | placeholder?: string; 214 | }) { 215 | return ( 216 | props.onChange(event.currentTarget.value)} 222 | /> 223 | ); 224 | } 225 | 226 | function InputCustom3(props: { 227 | value: string | number | string[]; 228 | onChange(value: string): void; 229 | testId: string; 230 | placeholder?: string; 231 | }) { 232 | return ( 233 | props.onChange(event.currentTarget.value)} 239 | /> 240 | ); 241 | } 242 | -------------------------------------------------------------------------------- /src/no-ssr/create-reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import { createReflect } from '@effector/reflect'; 2 | import { render } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { createEffect, createEvent, createStore, restore } from 'effector'; 5 | import React, { FC, InputHTMLAttributes } from 'react'; 6 | import { act } from 'react-dom/test-utils'; 7 | 8 | // Example1 (InputCustom) 9 | const InputCustom: FC<{ 10 | value: string | number | string[]; 11 | onChange(value: string): void; 12 | testId: string; 13 | placeholder?: string; 14 | }> = (props) => { 15 | return ( 16 | props.onChange(event.currentTarget.value)} 21 | /> 22 | ); 23 | }; 24 | 25 | const inputCustom = createReflect(InputCustom); 26 | 27 | test('InputCustom', async () => { 28 | const change = createEvent(); 29 | const $name = restore(change, ''); 30 | 31 | const Name = inputCustom({ value: $name, onChange: change }); 32 | 33 | const container = render(); 34 | 35 | expect($name.getState()).toBe(''); 36 | await userEvent.type(container.getByTestId('name'), 'Bob'); 37 | expect($name.getState()).toBe('Bob'); 38 | 39 | const inputName = container.container.firstChild as HTMLInputElement; 40 | expect(inputName.value).toBe('Bob'); 41 | }); 42 | 43 | test('InputCustom [replace value]', async () => { 44 | const change = createEvent(); 45 | const $name = createStore(''); 46 | 47 | $name.on(change, (_, next) => next); 48 | 49 | const Name = inputCustom({ name: $name, onChange: change }); 50 | 51 | const container = render(); 52 | 53 | expect($name.getState()).toBe(''); 54 | await userEvent.type(container.getByTestId('name'), 'Bob'); 55 | expect($name.getState()).toBe('Aliseb'); 56 | 57 | const inputName = container.container.firstChild as HTMLInputElement; 58 | expect(inputName.value).toBe('Alise'); 59 | }); 60 | 61 | // Example 2 (InputBase) 62 | const InputBase: FC> = (props) => { 63 | return ; 64 | }; 65 | 66 | const inputBase = createReflect(InputBase); 67 | 68 | test('InputBase', async () => { 69 | const changeName = createEvent(); 70 | const $name = restore(changeName, ''); 71 | 72 | const Name = inputBase({ 73 | value: $name, 74 | onChange: (event) => changeName(event.currentTarget.value), 75 | }); 76 | 77 | const changeAge = createEvent(); 78 | const $age = restore(changeAge, 0); 79 | const Age = inputBase({ 80 | value: $age, 81 | onChange: (event) => { 82 | changeAge(Number.parseInt(event.currentTarget.value, 10)); 83 | }, 84 | }); 85 | 86 | const container = render( 87 | <> 88 | 89 | 90 | >, 91 | ); 92 | 93 | expect($name.getState()).toBe(''); 94 | await userEvent.type(container.getByTestId('name'), 'Bob'); 95 | expect($name.getState()).toBe('Bob'); 96 | 97 | expect($age.getState()).toBe(0); 98 | await userEvent.type(container.getByTestId('age'), '25'); 99 | expect($age.getState()).toBe(25); 100 | 101 | const inputName = container.getByTestId('name') as HTMLInputElement; 102 | expect(inputName.value).toBe('Bob'); 103 | 104 | const inputAge = container.getByTestId('age') as HTMLInputElement; 105 | expect(inputAge.value).toBe('25'); 106 | }); 107 | 108 | describe('hooks', () => { 109 | describe('mounted', () => { 110 | test('callback', () => { 111 | const changeName = createEvent(); 112 | const $name = restore(changeName, ''); 113 | 114 | const mounted = vi.fn(() => {}); 115 | 116 | const Name = inputBase( 117 | { 118 | value: $name, 119 | onChange: changeName.prepend((event) => event.currentTarget.value), 120 | }, 121 | { hooks: { mounted } }, 122 | ); 123 | 124 | render(); 125 | 126 | expect(mounted.mock.calls.length).toBe(1); 127 | }); 128 | 129 | test('event', () => { 130 | const changeName = createEvent(); 131 | const $name = restore(changeName, ''); 132 | const mounted = createEvent(); 133 | 134 | const fn = vi.fn(() => {}); 135 | 136 | mounted.watch(fn); 137 | 138 | const Name = inputBase( 139 | { 140 | value: $name, 141 | onChange: changeName.prepend((event) => event.currentTarget.value), 142 | }, 143 | { hooks: { mounted } }, 144 | ); 145 | 146 | render(); 147 | 148 | expect(fn.mock.calls.length).toBe(1); 149 | }); 150 | }); 151 | 152 | describe('unmounted', () => { 153 | const changeVisible = createEffect({ handler: () => {} }); 154 | const $visible = restore( 155 | changeVisible.finally.map(({ params }) => params), 156 | true, 157 | ); 158 | 159 | const Branch = createReflect<{ visible: boolean }>(({ visible, children }) => 160 | visible ? <>{children}> : null, 161 | )({ 162 | visible: $visible, 163 | }); 164 | 165 | beforeEach(() => { 166 | act(() => { 167 | changeVisible(true); 168 | }); 169 | }); 170 | 171 | test('callback', () => { 172 | const changeName = createEvent(); 173 | const $name = restore(changeName, ''); 174 | 175 | const unmounted = vi.fn(() => {}); 176 | 177 | const Name = inputBase( 178 | { 179 | value: $name, 180 | onChange: changeName.prepend((event) => event.currentTarget.value), 181 | }, 182 | { hooks: { unmounted } }, 183 | ); 184 | 185 | render(, { wrapper: Branch }); 186 | 187 | act(() => { 188 | changeVisible(false); 189 | }); 190 | 191 | expect(unmounted.mock.calls.length).toBe(1); 192 | }); 193 | 194 | test('event', () => { 195 | const changeName = createEvent(); 196 | const $name = restore(changeName, ''); 197 | 198 | const unmounted = createEvent(); 199 | const fn = vi.fn(() => {}); 200 | 201 | unmounted.watch(fn); 202 | 203 | const Name = inputBase( 204 | { 205 | value: $name, 206 | onChange: changeName.prepend((event) => event.currentTarget.value), 207 | }, 208 | { hooks: { unmounted } }, 209 | ); 210 | 211 | render(, { wrapper: Branch }); 212 | 213 | act(() => { 214 | changeVisible(false); 215 | }); 216 | 217 | expect(fn.mock.calls.length).toBe(1); 218 | }); 219 | }); 220 | }); 221 | 222 | describe('useUnitConfig', () => { 223 | test('useUnit config should be passed to underlying useUnit', () => { 224 | expect(() => { 225 | const Name = inputBase({}, { useUnitConfig: { forceScope: true } }); 226 | render(); 227 | }).toThrowErrorMatchingInlineSnapshot( 228 | `[Error: No scope found, consider adding to app root]`, 229 | ); 230 | }); 231 | }); 232 | -------------------------------------------------------------------------------- /src/ssr/reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import { reflect } from '@effector/reflect/scope'; 2 | import { render } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { allSettled, createDomain, fork, restore } from 'effector'; 5 | import { Provider } from 'effector-react/scope'; 6 | import React, { ChangeEvent, FC, InputHTMLAttributes } from 'react'; 7 | 8 | // Example1 (InputCustom) 9 | const InputCustom: FC<{ 10 | value: string | number | string[]; 11 | onChange(value: string): void; 12 | testId: string; 13 | placeholder?: string; 14 | }> = (props) => { 15 | return ( 16 | props.onChange(event.currentTarget.value)} 21 | /> 22 | ); 23 | }; 24 | 25 | test('InputCustom', async () => { 26 | const app = createDomain(); 27 | 28 | const change = app.createEvent(); 29 | const $name = restore(change, ''); 30 | 31 | const Name = reflect({ 32 | view: InputCustom, 33 | bind: { value: $name, onChange: change }, 34 | }); 35 | 36 | const scope = fork(app); 37 | 38 | expect(scope.getState($name)).toBe(''); 39 | await allSettled(change, { scope, params: 'Bob' }); 40 | expect(scope.getState($name)).toBe('Bob'); 41 | 42 | const container = render( 43 | 44 | 45 | , 46 | ); 47 | 48 | const inputName = container.container.firstChild as HTMLInputElement; 49 | expect(inputName.value).toBe('Bob'); 50 | }); 51 | 52 | test('InputCustom [replace value]', async () => { 53 | const app = createDomain(); 54 | 55 | const change = app.createEvent(); 56 | const $name = app.createStore(''); 57 | 58 | $name.on(change, (_, next) => next); 59 | 60 | const Name = reflect({ 61 | view: InputCustom, 62 | bind: { name: $name, onChange: change }, 63 | }); 64 | 65 | const scope = fork(app); 66 | 67 | expect(scope.getState($name)).toBe(''); 68 | await allSettled(change, { scope, params: 'Bob' }); 69 | expect(scope.getState($name)).toBe('Bob'); 70 | 71 | const container = render( 72 | 73 | 74 | , 75 | ); 76 | 77 | const inputName = container.container.firstChild as HTMLInputElement; 78 | expect(inputName.value).toBe('Alise'); 79 | }); 80 | 81 | // Example 2 (InputBase) 82 | const InputBase: FC> = (props) => { 83 | return ; 84 | }; 85 | 86 | test('InputBase', async () => { 87 | const app = createDomain(); 88 | 89 | const changeName = app.createEvent(); 90 | const $name = restore(changeName, ''); 91 | 92 | const inputChanged = (event: ChangeEvent) => { 93 | return event.currentTarget.value; 94 | }; 95 | 96 | const Name = reflect({ 97 | view: InputBase, 98 | bind: { 99 | value: $name, 100 | onChange: changeName.prepend(inputChanged), 101 | }, 102 | }); 103 | 104 | const changeAge = app.createEvent(); 105 | const $age = restore(changeAge, 0); 106 | 107 | const Age = reflect({ 108 | view: InputBase, 109 | bind: { 110 | value: $age, 111 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 112 | }, 113 | }); 114 | 115 | const scope = fork(app); 116 | 117 | expect(scope.getState($name)).toBe(''); 118 | await allSettled(changeName, { scope, params: 'Bob' }); 119 | expect(scope.getState($name)).toBe('Bob'); 120 | 121 | expect(scope.getState($age)).toBe(0); 122 | await allSettled(changeAge, { scope, params: 25 }); 123 | expect(scope.getState($age)).toBe(25); 124 | 125 | const container = render( 126 | 127 | 128 | 129 | , 130 | ); 131 | 132 | const inputName = container.getByTestId('name') as HTMLInputElement; 133 | expect(inputName.value).toBe('Bob'); 134 | 135 | const inputAge = container.getByTestId('age') as HTMLInputElement; 136 | expect(inputAge.value).toBe('25'); 137 | }); 138 | 139 | test('with ssr for client', async () => { 140 | const app = createDomain(); 141 | 142 | const changeName = app.createEvent(); 143 | const $name = restore(changeName, ''); 144 | 145 | const Name = reflect({ 146 | view: (props: { 147 | value: string; 148 | onChange: (_event: ChangeEvent) => void; 149 | }) => { 150 | return ( 151 | 152 | ); 153 | }, 154 | bind: { 155 | value: $name, 156 | onChange: changeName.prepend((event) => event.currentTarget.value), 157 | }, 158 | }); 159 | 160 | const scope = fork(app); 161 | 162 | const container = render( 163 | 164 | 165 | , 166 | ); 167 | 168 | expect(scope.getState($name)).toBe(''); 169 | await userEvent.type(container.getByTestId('name'), 'Bob'); 170 | expect(scope.getState($name)).toBe('Bob'); 171 | 172 | const inputName = container.getByTestId('name') as HTMLInputElement; 173 | expect(inputName.value).toBe('Bob'); 174 | }); 175 | 176 | test('two scopes', async () => { 177 | const app = createDomain(); 178 | 179 | const changeName = app.createEvent(); 180 | const $name = restore(changeName, ''); 181 | 182 | const Name = reflect({ 183 | view: InputCustom, 184 | bind: { value: $name, onChange: changeName }, 185 | }); 186 | 187 | const scope1 = fork(app); 188 | const scope2 = fork(app); 189 | 190 | expect(scope2.getState($name)).toBe(''); 191 | await allSettled(changeName, { scope: scope2, params: 'Alise' }); 192 | expect(scope2.getState($name)).toBe('Alise'); 193 | 194 | expect(scope1.getState($name)).toBe(''); 195 | await allSettled(changeName, { scope: scope1, params: 'Bob' }); 196 | expect(scope1.getState($name)).toBe('Bob'); 197 | 198 | const container2 = render( 199 | 200 | 201 | , 202 | ); 203 | const container1 = render( 204 | 205 | 206 | , 207 | ); 208 | 209 | const inputName1 = container1.getByTestId('name1') as HTMLInputElement; 210 | const inputName2 = container2.getByTestId('name2') as HTMLInputElement; 211 | 212 | await allSettled(changeName, { scope: scope2, params: 'Alise' }); 213 | await allSettled(changeName, { scope: scope1, params: 'Bob' }); 214 | 215 | expect(scope1.getState($name)).toBe('Bob'); 216 | expect(scope2.getState($name)).toBe('Alise'); 217 | 218 | expect(inputName1.value).toBe('Bob'); 219 | expect(inputName2.value).toBe('Alise'); 220 | }); 221 | 222 | test('use only event for bind', async () => { 223 | const app = createDomain(); 224 | 225 | const changeName = app.createEvent(); 226 | const $name = restore(changeName, ''); 227 | 228 | const changeAge = app.createEvent(); 229 | 230 | const Name = reflect({ 231 | view: InputBase, 232 | bind: { 233 | value: $name, 234 | onChange: changeName.prepend((event) => event.currentTarget.value), 235 | }, 236 | }); 237 | 238 | const Age = reflect({ 239 | view: InputBase, 240 | bind: { 241 | onChange: changeAge.prepend((event) => 242 | Number.parseInt(event.currentTarget.value, 10), 243 | ), 244 | }, 245 | }); 246 | 247 | const scope = fork(app); 248 | 249 | const container = render( 250 | 251 | 252 | 253 | , 254 | ); 255 | 256 | const name = container.getByTestId('name') as HTMLInputElement; 257 | const age = container.getByTestId('age') as HTMLInputElement; 258 | 259 | expect(name.value).toBe(''); 260 | expect(age.value).toBe(''); 261 | }); 262 | -------------------------------------------------------------------------------- /public-types/reflect.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/consistent-type-definitions */ 2 | import type { EventCallable, Show, Store } from 'effector'; 3 | import type { useUnit } from 'effector-react'; 4 | import type { 5 | ComponentProps, 6 | ComponentType, 7 | FC, 8 | JSX, 9 | PropsWithChildren, 10 | } from 'react'; 11 | 12 | type UseUnitConfig = Parameters[1]; 13 | 14 | type UnbindableProps = 'key' | 'ref'; 15 | 16 | type Hooks = { 17 | mounted?: 18 | | EventCallable 19 | | EventCallable 20 | | ((props: Props) => unknown); 21 | unmounted?: 22 | | EventCallable 23 | | EventCallable 24 | | ((props: Props) => unknown); 25 | }; 26 | 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | type VoidCallback = T extends (...args: any[]) => infer R ? () => R : never; 29 | 30 | /** 31 | * `bind` object type: 32 | * prop key -> store (unwrapped to reactive subscription) or any other value (used as is) 33 | */ 34 | type BindFromProps = { 35 | [K in keyof Props]?: K extends UnbindableProps 36 | ? never 37 | : 38 | | Store 39 | | Props[K] 40 | // case: allow Event for callbacks with arbitrary arguments 41 | | VoidCallback; 42 | }; 43 | 44 | /** 45 | * Computes final props type based on Props of the view component and Bind object. 46 | * 47 | * Props that are "taken" by Bind object are made **optional** in the final type, 48 | * so it is possible to overwrite them in the component usage anyway 49 | */ 50 | type FinalProps> = Show< 51 | Omit & { 52 | [K in Extract]?: Props[K]; 53 | } 54 | >; 55 | 56 | // relfect types 57 | /** 58 | * Operator that creates a component, which props are reactively bound to a store or statically - to any other value. 59 | * 60 | * @example 61 | * ``` 62 | * const Name = reflect({ 63 | * view: Input, 64 | * bind: { 65 | * value: $name, 66 | * placeholder: 'Name', 67 | * onChange: (event) => nameChanged(event.target.value), 68 | * }, 69 | * }); 70 | * ``` 71 | */ 72 | export function reflect< 73 | View extends ComponentType, 74 | Props extends ComponentProps, 75 | Bind extends BindFromProps, 76 | >(config: { 77 | view: View; 78 | bind: Bind; 79 | hooks?: Hooks; 80 | /** 81 | * This configuration is passed directly to `useUnit`'s hook second argument. 82 | */ 83 | useUnitConfig?: UseUnitConfig; 84 | }): FC>; 85 | 86 | // Note: FC is used as a return type, because tests on a real Next.js project showed, 87 | // that if theoretically better option like (props: ...) => React.ReactNode is used, 88 | // then TS type inference works worse in some cases - didn't manage to reproduce it in a reflect type tests though. 89 | // 90 | // It is not clear why it works this way (FC return type is actually compatible with ReactNode), but it seems that FC is the best option here :shrug: 91 | 92 | // createReflect types 93 | /** 94 | * Method to create a `reflect` function with a predefined `view` component. 95 | * 96 | * @example 97 | * ``` 98 | * const reflectInput = createReflect(Input); 99 | * 100 | * const Name = reflectInput({ 101 | * value: $name, 102 | * placeholder: 'Name', 103 | * onChange: (event) => nameChanged(event.target.value), 104 | * }); 105 | * ``` 106 | */ 107 | export function createReflect< 108 | View extends ComponentType, 109 | Props extends ComponentProps, 110 | Bind extends BindFromProps, 111 | >( 112 | component: View, 113 | ): ( 114 | bind: Bind, 115 | features?: { 116 | hooks?: Hooks; 117 | /** 118 | * This configuration is passed directly to `useUnit`'s hook second argument. 119 | */ 120 | useUnitConfig?: UseUnitConfig; 121 | }, 122 | ) => FC>; 123 | 124 | // list types 125 | type PropsifyBind = { 126 | [K in keyof Bind]: Bind[K] extends Store ? Value : Bind[K]; 127 | }; 128 | 129 | type ReflectedProps = Item & PropsifyBind; 130 | 131 | /** 132 | * Operator to create a component, which reactivly renders a list of `view` components based on the `source` store with an array value. 133 | * Also supports `bind`, like the `reflect` operator. 134 | * 135 | * @example 136 | * ``` 137 | * const List = list({ 138 | * source: $items, 139 | * view: Item, 140 | * mapItem: { 141 | * id: (item) => item.id, 142 | * value: (item) => item.value, 143 | * onChange: (_item) => (_params) => {}, 144 | * }, 145 | *}); 146 | * 147 | * ``` 148 | */ 149 | export function list< 150 | View extends ComponentType, 151 | Props extends ComponentProps, 152 | Item, 153 | MapItem extends { 154 | [M in keyof Omit]: (item: Item, index: number) => Props[M]; 155 | }, 156 | Bind extends BindFromProps = object, 157 | >( 158 | config: ReflectedProps extends Props 159 | ? { 160 | source: Store; 161 | view: View; 162 | bind?: Bind; 163 | mapItem?: MapItem; 164 | getKey?: (item: Item) => React.Key; 165 | hooks?: Hooks; 166 | /** 167 | * This configuration is passed directly to `useUnit`'s hook second argument. 168 | */ 169 | useUnitConfig?: UseUnitConfig; 170 | } 171 | : { 172 | source: Store; 173 | view: View; 174 | bind?: Bind; 175 | mapItem: MapItem; 176 | getKey?: (item: Item) => React.Key; 177 | hooks?: Hooks; 178 | /** 179 | * This configuration is passed directly to `useUnit`'s hook second argument. 180 | */ 181 | useUnitConfig?: UseUnitConfig; 182 | }, 183 | ): FC; 184 | 185 | // variant types 186 | 187 | /** 188 | * Computes final props type based on Props of the view component and Bind object for variant operator specifically 189 | * 190 | * Difference is important since in variant case Props is a union 191 | * 192 | * Props that are "taken" by Bind object are made **optional** in the final type, 193 | * so it is possible to overwrite them in the component usage anyway 194 | */ 195 | type FinalPropsVariant> = Show< 196 | Props extends any 197 | ? Omit & { 198 | [K in Extract]?: Props[K]; 199 | } 200 | : never 201 | >; 202 | 203 | /** 204 | * Operator to conditionally render a component based on the reactive `source` store value. 205 | * 206 | * @example 207 | * ``` 208 | * // source is a store with a string 209 | * const Component = variant({ 210 | * source: $isError.map((isError) => (isError ? 'error' : 'success')), 211 | * cases: { 212 | * error: ErrorComponent, 213 | * success: SuccessComponent, 214 | * }, 215 | *}); 216 | * // shorthand for boolean source 217 | * const Component = variant({ 218 | * if: $isError, 219 | * then: ErrorComponent, 220 | * else: SuccessComponent, 221 | * }); 222 | * ``` 223 | */ 224 | export function variant< 225 | CaseType extends string, 226 | Cases extends Record>, 227 | Props extends ComponentProps, 228 | // It is ok here - it fixed bunch of type inference issues, when `bind` is not provided 229 | // but it is not clear why it works this way - Record or any option other than `{}` doesn't work 230 | // eslint-disable-next-line @typescript-eslint/ban-types 231 | Bind extends BindFromProps = {}, 232 | >( 233 | config: 234 | | { 235 | source: Store; 236 | cases: Partial; 237 | default?: ComponentType; 238 | bind?: Bind; 239 | hooks?: Hooks; 240 | /** 241 | * This configuration is passed directly to `useUnit`'s hook second argument. 242 | */ 243 | useUnitConfig?: UseUnitConfig; 244 | } 245 | | { 246 | if: Store; 247 | then: ComponentType; 248 | else?: ComponentType; 249 | bind?: Bind; 250 | hooks?: Hooks; 251 | /** 252 | * This configuration is passed directly to `useUnit`'s hook second argument. 253 | */ 254 | useUnitConfig?: UseUnitConfig; 255 | }, 256 | ): FC>; 257 | 258 | // fromTag types 259 | /** 260 | * 261 | * Simple helper to allow to use `reflect` with any valid html tag 262 | * 263 | * @example 264 | * ``` 265 | * import { reflect, fromTag } from '@effector/reflect' 266 | * 267 | * const DomInput = fromTag("input") 268 | * 269 | * const View = reflect({ 270 | * view: DomInput, 271 | * bind: { 272 | * type: 'radio', 273 | * value: $value, 274 | * onChange: (e) => e.target.value, 275 | * } 276 | * }) 277 | * ``` 278 | */ 279 | export function fromTag( 280 | htmlTag: HtmlTag, 281 | ): (props: PropsWithChildren) => React.ReactNode; 282 | -------------------------------------------------------------------------------- /src/no-ssr/variant.test.tsx: -------------------------------------------------------------------------------- 1 | import { variant } from '@effector/reflect'; 2 | import { act, render } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { createEvent, createStore, restore } from 'effector'; 5 | import React from 'react'; 6 | 7 | test('matches first', async () => { 8 | const changeValue = createEvent(); 9 | const changeType = createEvent<'first' | 'second' | 'third'>(); 10 | const $value = restore(changeValue, ''); 11 | const $type = restore(changeType, 'first'); 12 | 13 | const Input = variant({ 14 | source: $type, 15 | bind: { value: $value, onChange: changeValue }, 16 | cases: { 17 | first: InputCustom, 18 | second: InputCustom2, 19 | third: InputCustom3, 20 | }, 21 | }); 22 | 23 | const container = render(); 24 | 25 | await userEvent.type(container.getByTestId('check'), 'ForExample'); 26 | expect($value.getState()).toBe('ForExample'); 27 | 28 | const input = container.container.firstChild as HTMLInputElement; 29 | expect(input.className).toBe('first'); 30 | 31 | act(() => { 32 | changeType('second'); 33 | }); 34 | 35 | expect($value.getState()).toBe('ForExample'); 36 | const updatedInput = container.container.firstChild as HTMLInputElement; 37 | expect(updatedInput.className).toBe('second'); 38 | }); 39 | 40 | test('allows partial cases, renders default for unmatched', async () => { 41 | const changeValue = createEvent(); 42 | const changeType = createEvent<'first' | 'second' | 'third'>(); 43 | const $value = restore(changeValue, ''); 44 | const $type = restore(changeType, 'first'); 45 | 46 | const Input = variant({ 47 | source: $type, 48 | bind: { value: $value, onChange: changeValue }, 49 | cases: { 50 | second: InputCustom2, 51 | third: InputCustom3, 52 | }, 53 | default: InputCustom, 54 | }); 55 | 56 | const container = render(); 57 | 58 | await userEvent.type(container.getByTestId('check'), 'ForExample'); 59 | expect($value.getState()).toBe('ForExample'); 60 | 61 | const input = container.container.firstChild as HTMLInputElement; 62 | expect(input.className).toBe('first'); 63 | 64 | act(() => { 65 | changeType('second'); 66 | }); 67 | 68 | expect($value.getState()).toBe('ForExample'); 69 | const updatedInput = container.container.firstChild as HTMLInputElement; 70 | expect(updatedInput.className).toBe('second'); 71 | }); 72 | 73 | test.todo('rerenders only once after change source'); 74 | test.todo('rerenders only once on type'); 75 | test.todo('works on nested matches'); 76 | 77 | test('hooks works once on mount', async () => { 78 | const changeType = createEvent<'first' | 'second' | 'third'>(); 79 | const $type = restore(changeType, 'first'); 80 | const mounted = createEvent(); 81 | const fn = vi.fn(); 82 | mounted.watch(fn); 83 | 84 | const Input = variant({ 85 | source: $type, 86 | bind: { value: '', onChange: Function }, 87 | hooks: { mounted }, 88 | cases: { 89 | first: InputCustom, 90 | second: InputCustom2, 91 | third: InputCustom3, 92 | }, 93 | }); 94 | 95 | expect(fn).not.toBeCalled(); 96 | 97 | render(); 98 | expect(fn).toBeCalledTimes(1); 99 | 100 | act(() => { 101 | changeType('second'); 102 | }); 103 | expect(fn).toBeCalledTimes(1); 104 | }); 105 | 106 | test('hooks works once on unmount', async () => { 107 | const changeType = createEvent<'first' | 'second' | 'third'>(); 108 | const $type = restore(changeType, 'first'); 109 | const unmounted = createEvent(); 110 | const fn = vi.fn(); 111 | unmounted.watch(fn); 112 | const setVisible = createEvent(); 113 | const $visible = restore(setVisible, true); 114 | 115 | const Input = variant({ 116 | source: $type, 117 | bind: { value: '', onChange: Function }, 118 | hooks: { unmounted }, 119 | cases: { 120 | first: InputCustom, 121 | second: InputCustom2, 122 | third: InputCustom3, 123 | }, 124 | }); 125 | 126 | const Component = variant({ 127 | source: $visible.map(String), 128 | cases: { 129 | true: Input, 130 | }, 131 | }); 132 | 133 | expect(fn).not.toBeCalled(); 134 | 135 | render(); 136 | expect(fn).not.toBeCalled(); 137 | 138 | act(() => { 139 | setVisible(false); 140 | }); 141 | expect(fn).toBeCalledTimes(1); 142 | }); 143 | 144 | test('hooks works on remount', async () => { 145 | const changeType = createEvent<'first' | 'second' | 'third'>(); 146 | const $type = restore(changeType, 'first'); 147 | 148 | const unmounted = createEvent(); 149 | const onUnmount = vi.fn(); 150 | unmounted.watch(onUnmount); 151 | const mounted = createEvent(); 152 | const onMount = vi.fn(); 153 | mounted.watch(onMount); 154 | 155 | const setVisible = createEvent(); 156 | const $visible = restore(setVisible, true); 157 | 158 | const Input = variant({ 159 | source: $type, 160 | bind: { value: '', onChange: Function }, 161 | hooks: { unmounted, mounted }, 162 | cases: { 163 | first: InputCustom, 164 | second: InputCustom2, 165 | third: InputCustom3, 166 | }, 167 | }); 168 | 169 | const Component = variant({ 170 | source: $visible.map(String), 171 | cases: { 172 | true: Input, 173 | }, 174 | }); 175 | 176 | expect(onMount).not.toBeCalled(); 177 | expect(onUnmount).not.toBeCalled(); 178 | 179 | render(); 180 | expect(onMount).toBeCalledTimes(1); 181 | expect(onUnmount).not.toBeCalled(); 182 | 183 | act(() => { 184 | setVisible(false); 185 | }); 186 | expect(onUnmount).toBeCalledTimes(1); 187 | 188 | act(() => { 189 | setVisible(true); 190 | }); 191 | expect(onMount).toBeCalledTimes(2); 192 | expect(onUnmount).toBeCalledTimes(1); 193 | }); 194 | 195 | function InputCustom(props: { 196 | value: string | number | string[]; 197 | onChange(value: string): void; 198 | testId: string; 199 | placeholder?: string; 200 | }) { 201 | return ( 202 | props.onChange(event.currentTarget.value)} 208 | /> 209 | ); 210 | } 211 | 212 | function InputCustom2(props: { 213 | value: string | number | string[]; 214 | onChange(value: string): void; 215 | testId: string; 216 | placeholder?: string; 217 | }) { 218 | return ( 219 | props.onChange(event.currentTarget.value)} 225 | /> 226 | ); 227 | } 228 | 229 | function InputCustom3(props: { 230 | value: string | number | string[]; 231 | onChange(value: string): void; 232 | testId: string; 233 | placeholder?: string; 234 | }) { 235 | return ( 236 | props.onChange(event.currentTarget.value)} 242 | /> 243 | ); 244 | } 245 | 246 | describe('overload for Store', () => { 247 | function Button(props: { testId: string }) { 248 | return Button; 249 | } 250 | 251 | test('render "then" on true', () => { 252 | const $visible = createStore(true); 253 | 254 | const Component = variant({ 255 | if: $visible, 256 | then: Button, 257 | else: () => , 258 | }); 259 | 260 | const container = render(); 261 | expect(container.getByTestId('then')).not.toBeNull(); 262 | }); 263 | 264 | test('render "else" on false', () => { 265 | const $visible = createStore(false); 266 | 267 | const Component = variant({ 268 | if: $visible, 269 | then: Button, 270 | else: () => , 271 | }); 272 | 273 | const container = render(); 274 | expect(container.getByTestId('else')).not.toBeNull(); 275 | }); 276 | 277 | test('render "null" as default "else"', () => { 278 | const $visible = createStore(false); 279 | 280 | const Component = variant({ 281 | if: $visible, 282 | then: Button, 283 | }); 284 | 285 | const container = render(); 286 | expect(() => container.getByTestId('then')).toThrowError(); 287 | }); 288 | }); 289 | 290 | describe('useUnitConfig', () => { 291 | test('useUnit config should be passed to underlying useUnit', () => { 292 | expect(() => { 293 | const Test = variant({ 294 | source: createStore<'a' | 'b'>('a'), 295 | cases: { 296 | a: () => null, 297 | b: () => null, 298 | }, 299 | bind: {}, 300 | useUnitConfig: { 301 | forceScope: true, 302 | }, 303 | }); 304 | render(); 305 | }).toThrowErrorMatchingInlineSnapshot( 306 | `[Error: No scope found, consider adding to app root]`, 307 | ); 308 | }); 309 | test('useUnit config should be passed to underlying useUnit (bool overload)', () => { 310 | expect(() => { 311 | const Test = variant({ 312 | if: createStore(true), 313 | then: () => null, 314 | else: () => null, 315 | bind: {}, 316 | useUnitConfig: { 317 | forceScope: true, 318 | }, 319 | }); 320 | render(); 321 | }).toThrowErrorMatchingInlineSnapshot( 322 | `[Error: No scope found, consider adding to app root]`, 323 | ); 324 | }); 325 | }); 326 | -------------------------------------------------------------------------------- /type-tests/types-variant.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { reflect, variant } from '@effector/reflect'; 3 | import { Button } from '@mantine/core'; 4 | import { createEvent, createStore } from 'effector'; 5 | import React, { FC, PropsWithChildren, ReactNode } from 'react'; 6 | import { expectType } from 'tsd'; 7 | 8 | // basic variant usage 9 | { 10 | const Input: React.FC<{ 11 | value: string; 12 | onChange: (newValue: string) => void; 13 | }> = () => null; 14 | const DateTime: React.FC<{ 15 | value: string; 16 | onChange: (newValue: string) => void; 17 | }> = () => null; 18 | const $value = createStore(''); 19 | const changed = createEvent(); 20 | const $type = createStore<'input' | 'datetime'>('input'); 21 | 22 | const VariableInput = variant({ 23 | source: $type, 24 | bind: { 25 | value: $value, 26 | onChange: changed, 27 | }, 28 | cases: { 29 | input: Input, 30 | datetime: DateTime, 31 | }, 32 | }); 33 | 34 | ; 35 | } 36 | 37 | // variant allows to pass incompatible props between cases - resulting component will have union of all props from all cases 38 | { 39 | const Input: React.FC<{ 40 | value: string; 41 | onChange: (event: { target: { value: string } }) => void; 42 | }> = () => null; 43 | const DateTime: React.FC<{ 44 | value: string; 45 | onChange: (newValue: string) => void; 46 | }> = () => null; 47 | const $value = createStore(''); 48 | const changed = createEvent(); 49 | const $type = createStore<'input' | 'datetime'>('input'); 50 | 51 | const VariableInput = variant({ 52 | source: $type, 53 | bind: { 54 | value: $value, 55 | onChange: changed, 56 | }, 57 | cases: { 58 | input: Input, 59 | datetime: DateTime, 60 | }, 61 | }); 62 | 63 | ; 64 | ; 65 | { 68 | event.target.value; 69 | }} 70 | />; 71 | { 74 | // ok 75 | }} 76 | />; 77 | { 80 | event; 81 | }} 82 | />; 83 | { 88 | event; 89 | }} 90 | />; 91 | } 92 | 93 | // variant allows not to set every possble case 94 | // for e.g. if we want to cover only specific ones and render default for the rest 95 | { 96 | type PageProps = { 97 | context: { 98 | route: string; 99 | }; 100 | }; 101 | 102 | const HomePage: React.FC = () => null; 103 | const FaqPage: React.FC = () => null; 104 | const NotFoundPage: React.FC = () => null; 105 | const $page = createStore<'home' | 'faq' | 'profile' | 'products'>('home'); 106 | const $pageContext = $page.map((route) => ({ route })); 107 | 108 | const CurrentPage = variant({ 109 | source: $page, 110 | bind: { context: $pageContext }, 111 | cases: { 112 | home: HomePage, 113 | faq: FaqPage, 114 | }, 115 | default: NotFoundPage, 116 | }); 117 | 118 | ; 119 | ; 120 | // @ts-expect-error 121 | ; 122 | } 123 | 124 | // variant warns about wrong cases 125 | { 126 | type PageProps = { 127 | context: { 128 | route: string; 129 | }; 130 | }; 131 | 132 | const HomePage: React.FC = () => null; 133 | const FaqPage: React.FC = () => null; 134 | const NotFoundPage: React.FC = () => null; 135 | const $page = createStore<'home' | 'profile' | 'products'>('home'); 136 | const $pageContext = $page.map((route) => ({ route })); 137 | 138 | const CurrentPage = variant({ 139 | source: $page, 140 | bind: { context: $pageContext }, 141 | cases: { 142 | home: HomePage, 143 | // @ts-expect-error 144 | faq: FaqPage, 145 | }, 146 | default: NotFoundPage, 147 | }); 148 | 149 | ; 150 | ; 151 | // @ts-expect-error 152 | ; 153 | } 154 | 155 | // overload for boolean source 156 | { 157 | type PageProps = { 158 | context: { 159 | route: string; 160 | }; 161 | }; 162 | 163 | const $ctx = createStore({ route: 'home' }); 164 | 165 | const HomePage: React.FC = () => null; 166 | const FallbackPage: React.FC = () => null; 167 | const $enabled = createStore(true); 168 | 169 | const CurrentPageThenElse = variant({ 170 | if: $enabled, 171 | then: HomePage, 172 | else: FallbackPage, 173 | bind: { context: $ctx }, 174 | }); 175 | 176 | ; 177 | ; 178 | // @ts-expect-error 179 | ; 180 | 181 | const CurrentPageOnlyThen = variant({ 182 | if: $enabled, 183 | then: HomePage, 184 | bind: { context: $ctx }, 185 | }); 186 | ; 187 | ; 188 | // @ts-expect-error 189 | ; 190 | } 191 | 192 | // supports nesting 193 | { 194 | const Test = (props: { test: string }) => <>>; 195 | 196 | const $test = createStore('test'); 197 | const $bool = createStore(true); 198 | 199 | const NestedVariant = variant({ 200 | source: $test, 201 | cases: { 202 | test: variant({ 203 | if: $bool, 204 | then: reflect({ 205 | view: Test, 206 | bind: {}, 207 | }), 208 | }), 209 | }, 210 | }); 211 | 212 | ; 213 | } 214 | 215 | // allows variants of compatible types 216 | { 217 | const Test = (props: PropsWithChildren<{ test: string }>) => content; 218 | const Loader = () => <>loader>; 219 | 220 | const $test = createStore(false); 221 | 222 | const View = variant({ 223 | if: $test, 224 | then: reflect({ 225 | view: Test, 226 | bind: { 227 | test: $test.map(() => 'test'), 228 | }, 229 | }), 230 | else: Loader, 231 | }); 232 | 233 | ; 234 | // @ts-expect-error 235 | ; 236 | } 237 | 238 | // Issue #81 reproduce 1 239 | { 240 | const Component = (props: { name: string }) => { 241 | return null; 242 | }; 243 | 244 | const Variant = variant({ 245 | source: createStore<'a'>('a'), 246 | cases: { a: Component }, 247 | }); 248 | 249 | // should not report error here 250 | const element = ; 251 | } 252 | 253 | // Issue #81 reproduce 2 254 | { 255 | const $enabled = createStore(true); 256 | const $name = createStore('reflect'); 257 | 258 | const MyView: FC<{ name: string; slot: ReactNode }> = ({ name, slot }) => { 259 | return ( 260 | 261 | Hello, {name}! 262 | {slot} 263 | 264 | ); 265 | }; 266 | 267 | const ComponentWithReflectOnly = reflect({ 268 | bind: { 269 | name: $name, 270 | }, 271 | view: MyView, 272 | }); 273 | 274 | const ComponentWithVariantAndReflect = variant({ 275 | if: $enabled, 276 | then: reflect({ 277 | bind: { 278 | name: $name, 279 | }, 280 | view: MyView, 281 | }), 282 | }); 283 | 284 | // Should not report error for "Another slot type" 285 | const App = () => { 286 | return ( 287 | 288 | Good slot type} /> 289 | Another slot type(} /> 290 | Should report error for "name"} 292 | name="kek" 293 | /> 294 | 295 | ); 296 | }; 297 | } 298 | 299 | // Edge-case: Mantine Button with weird polymorphic factory (fails) 300 | // 301 | // This case is broken because Button has a weird polymorphic factory 302 | // that is not compatible with `variant` as of now - it produces weird and broken types for resulting component 303 | // Test is left here for the future reference, in case if it will be possible to fix 304 | { 305 | const ReflectedVariant = variant({ 306 | source: createStore<'button' | 'a'>('button'), 307 | bind: { 308 | size: 'xl', 309 | }, 310 | cases: { 311 | button: Button<'button'>, 312 | a: Button<'a'>, 313 | }, 314 | }); 315 | 316 | const ReflectedVariantBad = variant({ 317 | source: createStore<'button' | 'a'>('button'), 318 | bind: { 319 | // @ts-expect-error 320 | size: 52, 321 | }, 322 | cases: { 323 | button: Button<'button'>, 324 | a: Button<'a'>, 325 | }, 326 | }); 327 | 328 | ; 329 | ; 330 | // @ts-expect-error 331 | ; 332 | 333 | const IfElseVariant = variant({ 334 | if: createStore(true), 335 | then: Button<'button'>, 336 | // @ts-expect-error 337 | else: Button<'a'>, 338 | }); 339 | 340 | ; 341 | ; 342 | // @ts-expect-error 343 | ; 344 | } 345 | 346 | // variant should allow not-to pass required props - as they can be added later in react 347 | { 348 | const Input: React.FC<{ 349 | value: string; 350 | onChange: (newValue: string) => void; 351 | color: 'red'; 352 | }> = () => null; 353 | const $variants = createStore<'input' | 'fallback'>('input'); 354 | const Fallback: React.FC<{ kek?: string }> = () => null; 355 | const $value = createStore(''); 356 | const changed = createEvent(); 357 | 358 | const InputBase = reflect({ 359 | view: Input, 360 | bind: { 361 | value: $value, 362 | onChange: changed, 363 | }, 364 | }); 365 | 366 | const ReflectedInput = variant({ 367 | source: $variants, 368 | cases: { 369 | input: InputBase, 370 | fallback: Fallback, 371 | }, 372 | }); 373 | 374 | const App: React.FC = () => { 375 | // missing prop must still be required in react 376 | // but in this case it is not required, as props are conditional union 377 | return ; 378 | }; 379 | 380 | ; 381 | 382 | const AppFixed: React.FC = () => { 383 | return ; 384 | }; 385 | expectType(App); 386 | expectType(AppFixed); 387 | } 388 | -------------------------------------------------------------------------------- /src/ssr/list.test.tsx: -------------------------------------------------------------------------------- 1 | import { list } from '@effector/reflect/scope'; 2 | import { act, render } from '@testing-library/react'; 3 | import { allSettled, createDomain, fork } from 'effector'; 4 | import { Provider, useStore } from 'effector-react/scope'; 5 | import React, { FC, memo } from 'react'; 6 | 7 | const List: FC = (props) => { 8 | return {props.children}; 9 | }; 10 | 11 | const ListItem: FC<{ title: string; prefix?: string }> = (props) => { 12 | return ( 13 | {`${ 14 | props.prefix || '' 15 | }${props.title}`} 16 | ); 17 | }; 18 | 19 | test('relfect-list: renders list from store', async () => { 20 | const app = createDomain(); 21 | 22 | const $todos = app.createStore<{ title: string; body: string }[]>([ 23 | { title: 'Buy milk', body: 'Text' }, 24 | { title: 'Clean room', body: 'Text 2' }, 25 | { title: 'Do homework', body: 'Text 3' }, 26 | ]); 27 | 28 | const Items = list({ 29 | source: $todos, 30 | view: ListItem, 31 | bind: {}, 32 | mapItem: { 33 | title: (todo) => todo.title, 34 | }, 35 | }); 36 | 37 | const scope = fork(app); 38 | 39 | const container = render( 40 | 41 | 42 | 43 | 44 | , 45 | ); 46 | 47 | expect( 48 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 49 | ).toEqual(scope.getState($todos).map((todo) => todo.title)); 50 | }); 51 | 52 | test('relfect-list: reflect hooks called once for every item', async () => { 53 | const app = createDomain(); 54 | 55 | const $todos = app.createStore<{ title: string; body: string }[]>([ 56 | { title: 'Buy milk', body: 'Text' }, 57 | { title: 'Clean room', body: 'Text 2' }, 58 | { title: 'Do homework', body: 'Text 3' }, 59 | ]); 60 | 61 | const mounted = app.createEvent(); 62 | 63 | const fn = vi.fn(() => {}); 64 | 65 | mounted.watch(fn); 66 | 67 | const unmounted = app.createEvent(); 68 | 69 | const unfn = vi.fn(() => {}); 70 | 71 | unmounted.watch(unfn); 72 | 73 | const Items = list({ 74 | source: $todos, 75 | view: ListItem, 76 | bind: {}, 77 | hooks: { mounted, unmounted }, 78 | mapItem: { 79 | title: (todo) => todo.title, 80 | }, 81 | }); 82 | 83 | const scope = fork(app); 84 | 85 | const container = render( 86 | 87 | 88 | 89 | 90 | , 91 | ); 92 | 93 | expect(fn.mock.calls.length).toBe(scope.getState($todos).length); 94 | 95 | container.unmount(); 96 | 97 | expect(unfn.mock.calls.length).toBe(scope.getState($todos).length); 98 | }); 99 | 100 | test('reflect-list: rerenders on list changes', async () => { 101 | const app = createDomain(); 102 | 103 | const addTodo = app.createEvent<{ title: string; body: string }>(); 104 | const removeTodo = app.createEvent(); 105 | const $todos = app.createStore<{ title: string; body: string }[]>([ 106 | { title: 'Buy milk', body: 'Text' }, 107 | { title: 'Clean room', body: 'Text 2' }, 108 | { title: 'Do homework', body: 'Text 3' }, 109 | ]); 110 | 111 | $todos 112 | .on(addTodo, (todos, next) => todos.concat(next)) 113 | .on(removeTodo, (todos, toRemove) => 114 | todos.filter((todo) => todo.title !== toRemove), 115 | ); 116 | 117 | const Items = list({ 118 | source: $todos, 119 | view: ListItem, 120 | bind: {}, 121 | mapItem: { 122 | title: (todo) => todo.title, 123 | }, 124 | }); 125 | 126 | const scope = fork(app); 127 | 128 | const container = render( 129 | 130 | 131 | 132 | 133 | , 134 | ); 135 | 136 | expect( 137 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 138 | ).toEqual(scope.getState($todos).map((todo) => todo.title)); 139 | 140 | await act(async () => { 141 | await allSettled(addTodo, { 142 | scope, 143 | params: { title: 'Write tests', body: 'Text 4' }, 144 | }); 145 | }); 146 | 147 | expect( 148 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 149 | ).toEqual(scope.getState($todos).map((todo) => todo.title)); 150 | 151 | await act(async () => { 152 | await allSettled(removeTodo, { scope, params: 'Clean room' }); 153 | }); 154 | 155 | expect( 156 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 157 | ).toEqual(scope.getState($todos).map((todo) => todo.title)); 158 | }); 159 | 160 | test('reflect-list: reflect binds props to every item in the list', async () => { 161 | const app = createDomain(); 162 | 163 | const $todos = app.createStore<{ title: string; body: string }[]>([ 164 | { title: 'Buy milk', body: 'Text' }, 165 | { title: 'Clean room', body: 'Text 2' }, 166 | { title: 'Do homework', body: 'Text 3' }, 167 | ]); 168 | 169 | const $prefix = app.createStore(''); 170 | const prefix = app.createEvent(); 171 | 172 | $prefix.on(prefix, (_, next) => next); 173 | 174 | const Items = list({ 175 | source: $todos, 176 | view: ListItem, 177 | bind: { 178 | prefix: $prefix, 179 | }, 180 | mapItem: { 181 | title: (todo) => todo.title, 182 | }, 183 | }); 184 | 185 | const scope = fork(app); 186 | 187 | const container = render( 188 | 189 | 190 | 191 | 192 | , 193 | ); 194 | 195 | expect( 196 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 197 | ).toEqual(scope.getState($todos).map(() => scope.getState($prefix))); 198 | 199 | await act(async () => { 200 | await allSettled(prefix, { scope, params: 'Task: ' }); 201 | }); 202 | 203 | expect( 204 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 205 | ).toEqual(scope.getState($todos).map(() => scope.getState($prefix))); 206 | 207 | await act(async () => { 208 | await allSettled(prefix, { scope, params: '' }); 209 | }); 210 | 211 | expect( 212 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 213 | ).toEqual(scope.getState($todos).map(() => scope.getState($prefix))); 214 | }); 215 | 216 | interface MemberProps { 217 | id: number; 218 | name: string; 219 | } 220 | 221 | const Member: FC = (props) => { 222 | const { name, id } = props; 223 | 224 | return {name}; 225 | }; 226 | 227 | test('reflect-list: getKey option', async () => { 228 | const fn = vi.fn(); 229 | const fn2 = vi.fn(); 230 | const app = createDomain(); 231 | 232 | const renameUser = app.createEvent<{ id: number; name: string }>(); 233 | const removeUser = app.createEvent(); 234 | const sortById = app.createEvent(); 235 | const $members = app 236 | .createStore([ 237 | { name: 'alice', id: 1 }, 238 | { name: 'bob', id: 3 }, 239 | { name: 'carol', id: 2 }, 240 | ]) 241 | .on(renameUser, (list, { id, name }) => 242 | list.map((e) => (e.id === id ? { id, name } : e)), 243 | ) 244 | .on(removeUser, (list, id) => list.filter((e) => e.id !== id)) 245 | .on(sortById, (list) => [...list].sort((a, b) => a.id - b.id)); 246 | 247 | // plain 248 | const PlainMember = memo((props: MemberProps) => { 249 | fn2(props); 250 | return ; 251 | }); 252 | 253 | const PlainMemberList = () => { 254 | return ( 255 | 256 | {useStore($members).map((props) => ( 257 | 258 | ))} 259 | 260 | ); 261 | }; 262 | 263 | // reflect 264 | const ReflectMember: FC = (props) => { 265 | fn(props); 266 | return ; 267 | }; 268 | const ReflectList: FC = (props) => {props.children}; 269 | const Members = list({ 270 | source: $members, 271 | view: ReflectMember, 272 | bind: {}, 273 | mapItem: { 274 | id: (member) => member.id, 275 | name: (member) => member.name, 276 | }, 277 | getKey: (item) => item.id, 278 | }); 279 | 280 | const scope = fork(app); 281 | const App = () => ( 282 | 283 | 284 | 285 | 286 | 287 | 288 | ); 289 | 290 | const container = render(); 291 | 292 | // first check 293 | expect( 294 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 295 | Number(item.dataset.testid), 296 | ), 297 | ).toEqual(scope.getState($members).map((member) => member.id)); 298 | expect( 299 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 300 | Number(item.dataset.testid), 301 | ), 302 | ).toEqual(scope.getState($members).map((member) => member.id)); 303 | 304 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 305 | fn2.mock.calls.map(([arg]) => arg), 306 | ); 307 | 308 | await act(async () => { 309 | await allSettled(sortById, { scope }); 310 | }); 311 | 312 | // second check 313 | expect( 314 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 315 | Number(item.dataset.testid), 316 | ), 317 | ).toEqual(scope.getState($members).map((member) => member.id)); 318 | expect( 319 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 320 | Number(item.dataset.testid), 321 | ), 322 | ).toEqual(scope.getState($members).map((member) => member.id)); 323 | 324 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 325 | fn2.mock.calls.map(([arg]) => arg), 326 | ); 327 | 328 | await act(async () => { 329 | await allSettled(renameUser, { scope, params: { id: 2, name: 'charlie' } }); 330 | }); 331 | 332 | // third check 333 | expect( 334 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 335 | Number(item.dataset.testid), 336 | ), 337 | ).toEqual(scope.getState($members).map((member) => member.id)); 338 | expect( 339 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 340 | Number(item.dataset.testid), 341 | ), 342 | ).toEqual(scope.getState($members).map((member) => member.id)); 343 | 344 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 345 | fn2.mock.calls.map(([arg]) => arg), 346 | ); 347 | 348 | await act(async () => { 349 | await allSettled(removeUser, { scope, params: 2 }); 350 | }); 351 | 352 | // last check 353 | expect( 354 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 355 | Number(item.dataset.testid), 356 | ), 357 | ).toEqual(scope.getState($members).map((member) => member.id)); 358 | expect( 359 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 360 | Number(item.dataset.testid), 361 | ), 362 | ).toEqual(scope.getState($members).map((member) => member.id)); 363 | 364 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 365 | fn2.mock.calls.map(([arg]) => arg), 366 | ); 367 | }); 368 | -------------------------------------------------------------------------------- /type-tests/types-reflect.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { reflect } from '@effector/reflect'; 3 | import { Button } from '@mantine/core'; 4 | import { createEvent, createStore } from 'effector'; 5 | import React, { 6 | AnchorHTMLAttributes, 7 | ButtonHTMLAttributes, 8 | ComponentType, 9 | FC, 10 | LabelHTMLAttributes, 11 | PropsWithChildren, 12 | ReactNode, 13 | } from 'react'; 14 | import { expectType } from 'tsd'; 15 | 16 | // basic reflect 17 | { 18 | const Input: React.FC<{ 19 | value: string; 20 | onChange: (newValue: string) => void; 21 | color: 'red'; 22 | }> = () => null; 23 | const $value = createStore(''); 24 | const changed = createEvent(); 25 | 26 | const ReflectedInput = reflect({ 27 | view: Input, 28 | bind: { 29 | value: $value, 30 | onChange: changed, 31 | color: 'red', 32 | }, 33 | }); 34 | 35 | expectType(ReflectedInput); 36 | } 37 | 38 | // reflect should not allow wrong props 39 | { 40 | const Input: React.FC<{ 41 | value: string; 42 | onChange: (newValue: string) => void; 43 | color: 'red'; 44 | }> = () => null; 45 | const $value = createStore(''); 46 | const changed = createEvent(); 47 | 48 | const ReflectedInput = reflect({ 49 | view: Input, 50 | bind: { 51 | value: $value, 52 | onChange: changed, 53 | // @ts-expect-error 54 | color: 'blue', 55 | }, 56 | }); 57 | 58 | expectType(ReflectedInput); 59 | } 60 | 61 | // reflect should not allow wrong props in final types 62 | { 63 | const Input: React.FC<{ 64 | value: string; 65 | onChange: (newValue: string) => void; 66 | color: 'red'; 67 | }> = () => null; 68 | const $value = createStore(''); 69 | const changed = createEvent(); 70 | 71 | const ReflectedInput = reflect({ 72 | view: Input, 73 | bind: { 74 | value: $value, 75 | onChange: changed, 76 | }, 77 | }); 78 | 79 | const App: React.FC = () => { 80 | return ( 81 | 85 | ); 86 | }; 87 | expectType(App); 88 | } 89 | 90 | // reflect should allow not-to pass required props - as they can be added later in react 91 | { 92 | const Input: React.FC<{ 93 | value: string; 94 | onChange: (newValue: string) => void; 95 | color: 'red'; 96 | }> = () => null; 97 | const $value = createStore(''); 98 | const changed = createEvent(); 99 | 100 | const ReflectedInput = reflect({ 101 | view: Input, 102 | bind: { 103 | value: $value, 104 | onChange: changed, 105 | }, 106 | }); 107 | 108 | const App: React.FC = () => { 109 | // missing prop must still be required in react 110 | // @ts-expect-error 111 | return ; 112 | }; 113 | 114 | const AppFixed: React.FC = () => { 115 | return ; 116 | }; 117 | expectType(App); 118 | expectType(AppFixed); 119 | } 120 | 121 | // reflect should make "binded" props optional - so it is allowed to overwrite them in react anyway 122 | { 123 | const Input: React.FC<{ 124 | value: string; 125 | onChange: (newValue: string) => void; 126 | color: 'red'; 127 | }> = () => null; 128 | const $value = createStore(''); 129 | const changed = createEvent(); 130 | 131 | const ReflectedInput = reflect({ 132 | view: Input, 133 | bind: { 134 | value: $value, 135 | onChange: changed, 136 | }, 137 | }); 138 | 139 | const App: React.FC = () => { 140 | return ; 141 | }; 142 | 143 | const AppFixed: React.FC = () => { 144 | return ; 145 | }; 146 | expectType(App); 147 | expectType(AppFixed); 148 | } 149 | 150 | // reflect should not allow to override "binded" props with wrong types 151 | { 152 | const Input: React.FC<{ 153 | value: string; 154 | onChange: (newValue: string) => void; 155 | color: 'red'; 156 | }> = () => null; 157 | const $value = createStore(''); 158 | const changed = createEvent(); 159 | 160 | const ReflectedInput = reflect({ 161 | view: Input, 162 | bind: { 163 | value: $value, 164 | onChange: changed, 165 | color: 'red', 166 | }, 167 | }); 168 | 169 | const App: React.FC = () => { 170 | return ( 171 | 175 | ); 176 | }; 177 | expectType(App); 178 | } 179 | 180 | // reflect should allow to pass EventCallable as click event handler 181 | { 182 | const Button: React.FC<{ 183 | onClick: React.EventHandler>; 184 | }> = () => null; 185 | 186 | const reactOnClick = createEvent(); 187 | 188 | const ReflectedButton = reflect({ 189 | view: Button, 190 | bind: { 191 | onClick: reactOnClick, 192 | }, 193 | }); 194 | 195 | expectType(ReflectedButton); 196 | } 197 | 198 | // reflect should allow passing Event as callback to optional event handlers 199 | { 200 | const Button: React.FC<{ 201 | onOptional?: React.EventHandler>; 202 | onNull: React.MouseEventHandler
Easily render React components with everything you love from effector.
12 | Get started → 13 |