react-redux-typescript-guide icon indicating copy to clipboard operation
react-redux-typescript-guide copied to clipboard

suggestion: connected generic component

Open bboxstart opened this issue 7 years ago • 11 comments

The repository already contains nice examples of generic components (generic-list) and connected components (sfc-counter-connected), but I'm having problems with the correct declaration and usage of connected generic components.

I would like to be able to write something like: export const ConnectedListExtended<T> = connect<GenericListProps<T>, {}, OwnProps>(mapStateToProps)(GenericList<T>);

An example of the combination of these two examples would be really helpfull.

Thanks in advance!

bboxstart avatar Feb 09 '18 21:02 bboxstart

Hi! try it like this: function HelloContainer

export default function HelloContainer<T>() {
    return connect<StateFromProps, DispatchFromProps>(
        mapStateToProps, mapDispatchToProps
    )(Hello as new(props: ComponentProps<T>) => Hello<T>);
}
// src/containers/HelloContainer.tsx

import * as actions from '../actions/';
import { ComponentProps, DispatchFromProps, StateFromProps, StoreState } from '../types';
import { Dispatch } from 'react-redux';
import '../components/Hello.css';
import { connect } from 'react-redux';
import Hello from '../components/Hello';

function mapStateToProps({ enthusiasmLevel }: StoreState): StateFromProps {
    return {
        enthusiasmLevel,
    };
}

function mapDispatchToProps(
    dispatch: Dispatch<actions.EnthusiasmAction>
): DispatchFromProps {
    return {
        onIncrement: () => dispatch(actions.incrementEnthusiasm()),
        onDecrement: () => dispatch(actions.decrementEnthusiasm()),
    };
}

export default function HelloContainer<T>() {
    return connect<StateFromProps, DispatchFromProps>(
        mapStateToProps, mapDispatchToProps
    )(Hello as new(props: ComponentProps<T>) => Hello<T>);
}

use function HelloContainer

// src/index.tsx

import * as React from 'react';
import * as ReactDOM from 'react-dom';
import registerServiceWorker from './registerServiceWorker';
import './index.css';
import { createStore } from 'redux';
import { enthusiasm } from './reducers';
import { StoreState } from './types';
import HelloContainer from './containers/HelloContainer';
import { Provider } from 'react-redux';

const store = createStore<StoreState>(enthusiasm, {
    enthusiasmLevel: 1,
});

// Assign a type
const HelloNumber = HelloContainer<number>();
const HelloString = HelloContainer<string>();

ReactDOM.render(
    <Provider store={store}>
        <div>
            <HelloNumber
                name={555}
            />
            <HelloString
                name={'TypeScript'}
            />
        </div>
    </Provider>,
    document.getElementById('root') as HTMLElement
);
registerServiceWorker();

// src/types/index.tsx

export interface StoreState {
    enthusiasmLevel: number;
}

export interface StateFromProps {
    enthusiasmLevel: number;
}

// merged type
export declare type ComponentProps<T> = StateFromProps & OwnProps<T> & DispatchFromProps;

// the type we want to make variable
export interface OwnProps<T> {
    name: T;
}

export interface DispatchFromProps {
    onIncrement: () => void;
    onDecrement: () => void;
}

// src/components/Hello.tsx

import * as React from 'react';
import './Hello.css';
import { ComponentProps } from '../types';

class Hello<T> extends React.Component<ComponentProps<T>> {
    constructor(props: ComponentProps<T>) {
        super(props);
    }
    render() {
        const { name, enthusiasmLevel = 1, onIncrement, onDecrement } = this.props;

        if (enthusiasmLevel <= 0) {
            throw new Error('You could be a little more enthusiastic. :D');
        }

        return (
            <div className="hello">
                <div className="greeting">
                    Hello {name + getExclamationMarks(enthusiasmLevel)}
                </div>
                <div>
                    <button onClick={onDecrement}>-</button>
                    <button onClick={onIncrement}>+</button>
                </div>
            </div>
        );
    }
}

export default Hello;

// helpers

function getExclamationMarks(numChars: number) {
    return Array(numChars + 1).join('!');
}

Zummer avatar Feb 14 '18 09:02 Zummer

The example above from @Zummer works like a charm. I'll try to create a pull request for this example.

bboxstart avatar Feb 28 '18 20:02 bboxstart

@Zummer Thanks for your great answer and also inspiring me to use less verbose interface names!

mellis481 avatar Oct 02 '18 16:10 mellis481

What about this setup:

import * as React from 'react'
import { connect } from 'react-redux'

const mapStateToProps = (storeSate: any) => {
  return {
    foo: 144
  }
}

const container = connect(mapStateToProps)

interface TInjectedProps {
  foo: number
}

export function hoc1<TRequiredProps extends TInjectedProps>(Component: React.ComponentType<TRequiredProps>) {
  const connected = container(Component)
}

export function hoc2<TRequiredProps>(Component: React.ComponentType<TRequiredProps & TInjectedProps>) {
  const connected = container(Component)
}

export function hoc3<TRequiredProps extends {}>(Component: React.ComponentType<TRequiredProps & TInjectedProps>) {
  const connected = container(Component)
}

In all three cases I get the error:

Type 'TInjectedProps[P]' is not assignable to type
    'P extends "foo" | "dispatch"
        ? ({ foo: number; } & DispatchProp<AnyAction>)[P] extends TRequiredProps[P]

            ? TRequiredProps[P]
            : ({ foo: number; } & DispatchProp<AnyAction>)[P]
        
        : TRequiredProps[P]'.
@types/react: ^16.4.14
@types/react-dom: ^16.0.8
@types/react-redux: ^6.0.9
typescript: 3.1.1

rjdestigter avatar Oct 03 '18 15:10 rjdestigter

+1

import React from "react";
import { Subtract } from "utility-types";
import { connect } from "react-redux";
import { rangeVisibilitySelector } from "./date-range.selectors";

interface IInjectedProps {
  visible: boolean;
}

interface IMappedProps {
  isVisible: boolean;
}

const withIsVisibleRange = <T extends IInjectedProps>(
  Component: React.ComponentType<T>
) => {
  const WrappedComponent: React.SFC<
    Subtract<T, IInjectedProps> & IMappedProps
  > = ({ isVisible, ...rest }: IMappedProps) => {
    return <Component {...rest} visible={isVisible} />;
  };

  const mapStateToProps = (state: ApplicationState) => ({
    isVisible: rangeVisibilitySelector(state)
  });

  return connect(
    mapStateToProps,
    null
  )(WrappedComponent);
};

export default withIsVisibleRange;

In this case I get:

Error:(30, 5) TS2345: Argument of type 'StatelessComponent<Pick<T, SetDifference<keyof T, "visible">> & IMappedProps>' is not assignable to parameter of type 'ComponentType<Matching<{ isVisible: boolean; } & null, Pick<T, SetDifference<keyof T, "visible">> & IMappedProps>>'. Type 'StatelessComponent<Pick<T, SetDifference<keyof T, "visible">> & IMappedProps>' is not assignable to type 'StatelessComponent<Matching<{ isVisible: boolean; } & null, Pick<T, SetDifference<keyof T, "visible">> & IMappedProps>>'. Type 'Pick<T, SetDifference<keyof T, "visible">> & IMappedProps' is not assignable to type 'Matching<{ isVisible: boolean; } & null, Pick<T, SetDifference<keyof T, "visible">> & IMappedProps>'. Type '(Pick<T, SetDifference<keyof T, "visible">> & IMappedProps)[P]' is not assignable to type 'P extends "isVisible" ? ({ isVisible: boolean; } & null)[P] extends (Pick<T, SetDifference<keyof T, "visible">> & IMappedProps)[P] ? (Pick<T, SetDifference<keyof T, "visible">> & IMappedProps)[P] : ({ ...; } & null)[P] : (Pick<...> & IMappedProps)[P]'.

zhukevgeniy avatar Oct 16 '18 20:10 zhukevgeniy

@issuehunt has funded $50.00 to this issue.


IssueHuntBot avatar Apr 13 '19 06:04 IssueHuntBot

@Zummer: is there an equivalent to your suggestion above, but for use with functional components rather than class components?

yonigibbs avatar Jul 21 '19 08:07 yonigibbs

Actually, scratch that, think I found it:

type Props<T> = {
    ...
}

const MyComponent = <T extends {}>(props: Props<T>) : JSX.Element => {
    // render something
}

export default function MyConnectedComponent<T>() {
    return connect(mapStateToProps, mapDispatchToProps)(
        MyComponent as (props: Props<T>) => JSX.Element)
}

Seems to work. Anyone got any thoughts on whether this is/isn't a good approach?

One thing I wondered was what is the best thing to return from the MyComponent function (and the call to it in connect): should I return JSX.Element or React.ReactElement<Props<T>>. JSX.Element extends React.ReactElement<any, any> so by returning JSX.Element we seem to be losing the generic type definition, but I'm not sure if that will actually affect anything.

Thanks for the original workaround, @Zummer. Very helpful!

yonigibbs avatar Jul 22 '19 07:07 yonigibbs

Still can't get this working. I have this per comments, and no typescript errors, but the component doesn't render anything. export function CMSContent<T>() { return connect<StateFromProps>(mapStateToProps)(ContentComponent as (props: Props<T>) => JSX.Element); }

My thoughts is it would need to be something like this export function CMSContent<T>(props:Props<T>) { return connect<StateFromProps>(mapStateToProps)(ContentComponent as (props: Props<T>) => JSX.Element); } But I don't know what to do with props to pass them down into the connected component and have it render

pbn04001 avatar Jan 30 '21 17:01 pbn04001

This is how I am doing now a days... hope it helps.

typescript class with redux

export class MyViewClass extends React.Component<RoutedProps<BaseProps>, AppState<BaseState>> {
 // my view class implementation
}

export const mapStoreToProps = (store: MyStore): BaseProps => ({
  isAuthenticated: store.authentication.isAuthenticated,
  user: store.authentication.user,
  isLoading: store.company.isLoading,
});

const mapDispatchToProps = (dispatch: any): DispatchProps => ({
  logout: () => dispatch(logout()),
  loadCompany: (companyId: number) => dispatch(loadCompany(companyId)),
});

export const MyView = withRouter(connect(mapStoreToProps, mapDispatchToProps)(MyViewClass));

Hooks same way... just change the name actually

React Hooks With Typescript

export const MyHookFC: React.FC<NoResultFeatureProps> = (props: NoResultFeatureProps) => {
  // my hook implementation
}

export const mapStoreToProps = (store: MyStore): BaseProps => ({
  isAuthenticated: store.authentication.isAuthenticated,
  user: store.authentication.user,
  isLoading: store.company.isLoading,
});

const mapDispatchToProps = (dispatch: any): DispatchProps => ({
  logout: () => dispatch(logout()),
  loadCompany: (companyId: number) => dispatch(loadCompany(companyId)),
});

export const MyHook = withRouter(connect(mapStoreToProps, mapDispatchToProps)(MyHookFC));

danielrsantana-sastrix avatar Jan 30 '21 21:01 danielrsantana-sastrix

This is how I am doing now a days... hope it helps.

typescript class with redux

export class MyViewClass extends React.Component<RoutedProps<BaseProps>, AppState<BaseState>> {
 // my view class implementation
}

export const mapStoreToProps = (store: MyStore): BaseProps => ({
  isAuthenticated: store.authentication.isAuthenticated,
  user: store.authentication.user,
  isLoading: store.company.isLoading,
});

const mapDispatchToProps = (dispatch: any): DispatchProps => ({
  logout: () => dispatch(logout()),
  loadCompany: (companyId: number) => dispatch(loadCompany(companyId)),
});

export const MyView = withRouter(connect(mapStoreToProps, mapDispatchToProps)(MyViewClass));

Hooks same way... just change the name actually

React Hooks With Typescript

export const MyHookFC: React.FC<NoResultFeatureProps> = (props: NoResultFeatureProps) => {
  // my hook implementation
}

export const mapStoreToProps = (store: MyStore): BaseProps => ({
  isAuthenticated: store.authentication.isAuthenticated,
  user: store.authentication.user,
  isLoading: store.company.isLoading,
});

const mapDispatchToProps = (dispatch: any): DispatchProps => ({
  logout: () => dispatch(logout()),
  loadCompany: (companyId: number) => dispatch(loadCompany(companyId)),
});

export const MyHook = withRouter(connect(mapStoreToProps, mapDispatchToProps)(MyHookFC));

This still doesn't fix the issue of using generics on your class properties. Here is my class code.

interface Props<T> {
  view: CmsView,
  children: (content: T) => ReactNode,
  contentKey: keyof HomeQuery,
}

class ContentComponent<T> extends Component<Props<T>> {
  render() {
    const cms = getCMS(store.getState());
    const { view, contentKey, children } = this.props;
    let content: T | undefined;
    if (view === CmsView.HOME) {
      content = cms?.home?.[contentKey] as T;
    }
    if (content) {
      return <>{children(content)}</>;
    }
    return null;
  }
}

export const CMSContent = connect(mapStateToProps)(ContentComponent);

But I still get this warning when I try to use the component.

<CMSContent<HomepagePromoBannerCollection>
  contentKey="homepagePromoBannerCollection"
  view={CmsView.HOME}
>

TS2558: Expected 0 type arguments, but got 1.

pbn04001 avatar Feb 01 '21 16:02 pbn04001