How to define generic HOC with additional interface to add to props - javascript

I need to create a generic HOC with will accpect an interface which will be added to component props.
I have implemented following function, but it requires two arguments instead of one. I want second argument to be taken from the Component that it's passed into the function.
export const withMoreProps = <NewProps, Props>(WrappedComponent: React.FC<Props>): React.FC<Props & NewProps> => {
const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
const ComponentWithMoreProps = (props: Props & NewProps) => <WrappedComponent {...props} />;
ComponentWithMoreProps.displayName = `withMoreProps(${displayName})`;
return ComponentWithMoreProps;
};
Currently when I try to use this:
const Button = (props: { color: string }) => <button style={{ color: props.color }}>BTN</button>;
export const Button2 = withMoreProps<{ newProperty: string }>(Button);
I get this error message
Expected 2 type arguments, but got 1.
It should work like styled-components, where you can define only additional props.
export const StyledButton = styled(Button)<{ withPadding?: boolean }>`
padding: ${({ withPadding }) => (withPadding ? '8px' : 0)};
`;
EDIT:
This is simplified version of HOC I have created in application. The real HOC is much more complex and does other stuff, but for sake of simplification I made this example to focus only on the problem I run into.

In general, you want to use the infer keyword. You can read more about it here, but in short you can think of it as a helper to "extract" a type out of a generic type.
Let's define a type that extract the prop type out of a react component.
type InferComponentProps<T> = T extends React.FC<infer R> ? R : never;
example on what it does:
const Button = (props: { color: string }) => <button style={{ color: props.color }}>BTN</button>;
type ButtonProps = InferComponentProps<typeof Button>; // hover over ButtonProps, see {color: string}
Now that we have this "helper type", we can move on to implement what you want - but we do run into an issue. When calling a generic function in typescript, you can't specify some of the types, and some no.
You either specify all the concrete types matching for this function call, or specify none, and let Typescript figure out the types.
function genericFunction<T1, T2>(t1: T2) {
//...
}
const a = genericFunction('foo') //ok
const b = genericFunction<number, string>('foo') //ok
const c = genericFunction<string>('foo') //error
You can track the typescript issue here.
So to solve this we need to do a small change to your code and do a function that returns a function that returns the new component. If you notice, it's exactly how styled works also, as using tagged template literals is really a function call. So there are 2 function calls in the styled components code you posted above.
so the final code looks something like this:
export const withMoreProps =
<C extends React.FC<any>>(WrappedComponent: C) =>
<NewProps extends Object>(): React.FC<InferComponentProps<C> & NewProps> => {
const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
//need to re-type the component
let WrappedComponentNew = WrappedComponent as React.FC<InferComponentProps<C> & NewProps>;
const ComponentWithMoreProps = (props: InferComponentProps<C> & NewProps) => <WrappedComponentNew {...props} />;
ComponentWithMoreProps.displayName = `withMoreProps(${displayName})`;
return ComponentWithMoreProps;
};
const Button = (props: { color: string }) => <button style={{ color: props.color }}>BTN</button>;
export const Button2 = withMoreProps(Button)<{ newProperty: string }>(); //Notice the function call at the end

If you just want a generic way to add more props to a component, you don't need the overhead of a HOC for this. You can easily achieve this using the rest and spread operator to pass on the props. (I use color on the HOC here unlike OP's example where it's on the main button, it's just a nice example)
const ColoredButton = ({color, ...other}) => <Button {...other, style: {color}/>
It's maybe slightly more code than the HOC version that basically handles passing on ...other for you. However in return:
You don't get a weird display name (withMoreProps(Button) which could be any props). Instead it will just use the function name like any other React component (e.g. ColoredButton). You'd rather have the latter while debugging.
The resulting component is as flexible as any other React component. Just add logic to the function body if you find you need it. But with HOCs there is no function body. You could add more than 1 HOC but that gets unwieldy very quickly.
Similarly, your issue with declaring the types simply goes away. It works exactly the same like the main button type.
const Button = (props: { color: string }) => <button style={{ color: props.color }}>BTN</button>;
export const Button2 = ({ newProperty: string, ...other }) => <Button {...other, newProperty}/>
// Original for comparison.
export const Button3 = withMoreProps<{ newProperty: string }>(Button);

Related

JS/TS: object destructuring

i've a variable named props.
The type extends from VariantTheme, VariantSize, VariantGradient, & React.DOMAttributes<HTMLOrSVGElement>
Then i want to make one more variable, let say htmlProps.
I want to fill the htmlProps from props.
I don't want it filled with a foreign props other than listed on React.DOMAttributes<HTMLOrSVGElement>.
Then spread the filtered prop into react component.
Here the not working code:
export interface Props
extends
VariantTheme,
VariantSize,
VariantGradient,
React.DOMAttributes<HTMLOrSVGElement>
{
tag? : keyof JSX.IntrinsicElements
classes? : string[]
}
export default function Element(props: Props) {
const elmStyles = styles.useStyles();
// themes:
const variTheme = useVariantTheme(props);
const variSize = useVariantSize(props);
const variGradient = useVariantGradient(props);
const Tag = (props.tag ?? 'div');
const htmlProps = props as React.HTMLAttributes<HTMLOrSVGElement>; // not working! Still containing strange props from VariantTheme, VariantSize, tag, classes, ...
return (
<Tag {...htmlProps}
className={[
elmStyles.main,
// themes:
variTheme.class,
variSize.class,
variGradient.class,
...(props.classes ?? []),
].filter((c) => !!c).join(' ')}
>
{(props as React.PropsWithChildren<Props>)?.children}
</Tag>
);
};
Do you have an idea to fix my code?
#spender already gave you the answer. Typescript only exists at compile time and what you are really doing is telling the compiler that props is now of type React.HTMLAttributes<HTMLOrSVGElement>, but you are still responsible on how the props are composed. In sum typescript does not alter how your javascript code works, it only helps you find mistakes at compile time.
One solution for your use case is to have htmlProps as a property of props and then spread these to Tag.
export interface Props
extends
VariantTheme,
VariantSize,
VariantGradient
{
tag? : keyof JSX.IntrinsicElements
classes? : string[],
htmlProps: React.DOMAttributes<HTMLOrSVGElement>, // dom attributes is now a property in props
}
export default function Element(props: Props) {
const elmStyles = styles.useStyles();
// themes:
const variTheme = useVariantTheme(props);
const variSize = useVariantSize(props);
const variGradient = useVariantGradient(props);
const Tag = (props.tag ?? 'div');
return (
<Tag {...props.htmlProps} // you can now pass the desired props to tag
className={[
elmStyles.main,
// themes:
variTheme.class,
variSize.class,
variGradient.class,
...(props.classes ?? []),
].filter((c) => !!c).join(' ')}
>
{(props as React.PropsWithChildren<Props>)?.children}
</Tag>
);
};
But #spender's suggestion is a better approach.
Don’t forget to update your hooks to accommodate this change.

Pass a Component with props, inside a Higher-Order-Component (HOC)

I refer to this example of creating a React Higher Order Component, taken from this article.
I am struggling to fully comprehend & make use of this HOC.
interface PopupOnHoverPropType {
hoverDisplay: string;
}
const WithPopupOnHover = <P extends object>(BaseComponent: React.FC<P>): React.FC<P & PopupOnHoverPropType> => ({ hoverDisplay, ...props }: PopupOnHoverPropType) => {
const [displayMsg, setDisplayMsg] = useState<boolean>(false);
const toggleDisplayMsg = () => {
setDisplayMsg(displayMsg);
};
return (
<div onMouseEnter={() => setDisplayMsg(true)}>
<div className={`${hoverDisplay} popup`} onClick={toggleDisplayMsg}/>
<BaseComponent {...props as P} />
</div>
)
};
Here is what my understanding of the above code is:
We define a HOC by the name of WithPopupOnHover,
which takes a React component (BaseComponent).
The BaseComponent comes with its props (referred as P)
which returns a new React component,
which takes props P & the props within PopupOnHoverPropType.
How do I use this HOC ?
The following attempt gives a typescript error that too many arguments are passed in:
const enhanced = WithPopupOnHover(Modal);
const m = Enhanced(a,b,c, "up");
The Modal React component has the following props structure:
const Modal: React.FC<{
a: string;
b(): void;
c(locationData: customType): void;
}> = { ... }
Looks like you just forgot the object brackets. React components all take a single props object argument.
const Enhanced = WithPopupOnHover(Modal);
const m = Enhanced({ a, b, c });

How to test if a component is rendered with the right props when using react-testing-library?

I have some components that are rendering another component (FetchNextPageButton) that is already tested in isolation, like these ones:
const News = () => (
<div>
<h1>News</h1>
...
<FetchNextPageButton query={NEWS_QUERY} path="viewer.news" />
</div>
)
const Jobs = () => (
<div>
<h1>Jobs</h1>
...
<FetchNextPageButton query={JOBS_QUERY} path="viewer.jobs" />
</div>
)
const Posts = () => (
<div>
<h1>Posts</h1>
...
<FetchNextPageButton query={POSTS_QUERY} path="viewer.posts" />
</div>
)
The thing is that I'd not like having to add tests on each of these components for a functionality that is already tested somewhere else, so I think that should be enough just to test that the component is rendered and that I'm passing the right props to it.
I'd have been able to test this easily with Enzyme with something like this:
expect(wrapper.find('FetchNextPageButton').props()).toMatchObject({
query: NEWS_QUERY,
path: "viewer.news"
})
So I'm wondering what's the best approach to test it by using React testing library instead.
This is the approach that Kent C. Dodds (the creator of RTL) shared with me after discussing it with him:
import FetchNextPageButton from 'FetchNextPageButton'
jest.mock('FetchNextPageButton', () => {
return jest.fn(() => null)
})
// ... in your test
expect(FetchNextPageButton).toHaveBeenCalledWith(props, context)
Don't believe it's possible. RTL looks like focusing on validating against DOM not React's components tree.
The only workaround I see is to mock FetchNextPageButton to make it rendering all props into attributes.
jest.mock("../../../FetchNextPageButton.js", () =>
(props) => <div data-test-id="FetchNextPageButton" {...props} />);
....
const { getByTestId } = render(<YourComponent />);
expect(getByTestId("FetchNextPageButton")).toHaveAttribute("query", NEWS_QUERY);
expect(getByTestId("FetchNextPageButton")).toHaveAttribute("path", "viewer.news");
Sure, this is smoothly only for primitive values in props, but validating something like object or function would be harder.
Think, it's not RTL-way, but I agree it would be massive work to check that in scope of each container(and completely ignoring that would be rather a risk).
PS toHaveAttribute is from jest-dom
In my case, I wanted to test that a Higher Order Component (HOC), correctly enhances the component that is passed to the HOC.
What I needed to do, is make the actual component a mock and pass it to the HOC. Like described in the existing answer, you can then just expect the properties, added by the HOC.
// after 'Component' get's passed into withSelectionConstraint, it should have an id prop
const Component = jest.fn(() => <h1>Tag Zam</h1>);
const WithConstraint = withSelectionConstraint(Component, ["instance"], true);
render(<WithConstraint />);
// passing the jest mock to the HOC, enables asserting the actual properties passed by the HOC
expect(Component).toHaveBeenCalledWith(
expect.objectContaining({ ids: mockInstanceRows.map(x => x.id) }),
expect.anything()
)
Based on Ben's answer, I wrote a version which doesn't raise any error :
jest.mock(
'path/to/your/component',
() => {
const MockedComponent = (props: any) => {
const cleanedProps = Object.keys(props).reduce<Record<string, unknown>>(
(a, b) => {
// Needed because html attributes cannot be camel cased
a[b.toLowerCase()] = props[b].toString();
return a;
},
{}
);
return (
<div data-testid="any-test-id" {...cleanedProps} />
);
};
return MockedComponent;
}
);
Note that the attributes values (expect(getByTestId('any-test-id')).toHaveAttribute('attribute','value')) will be stringified.

Dynamic component name - React

I have three components:
const Comp0 = () => <div>1</div>;
const Comp1 = () => <div>2</div>;
const Comp2 = () => <div>3</div>;
I have also a class, with state:
state = { activeComponent: 0 }
This activeComponent can be changed by user to 1, 2 or 0.
In render, I have:
return (
{React.createElement(`Comp${this.state.activeComponent}`)};
}
It should work... theoretically. However - Im getting a really weird error. Two errors.
Warning: <Comp0 /> is using uppercase HTML. Always use lowercase HTML tags in React.
Warning: The tag <Comp0> is unrecognized in this browser. If you meant to render a React component, start its name with an uppercase letter.
How is that possible that they appear simultaneously?
You could simply render the dynamic tag like
const Tag = `Comp${this.state.activeComponent}`;
return (
<Tag />
}
According to the docs:
You cannot use a general expression as the React element type. If you
do want to use a general expression to indicate the type of the
element, just assign it to a capitalized variable first.
In your case it doesn't work because, you are passing the string name to React.createElement whereas for a React Component you need to pass the component like
React.createElement(Comp0);
and for a normal DOM element you would pass a string like
React.createElement('div');
and since you write
`Comp${this.state.activeComponent}`
what you get is
React.createElement('Comp0')
which isn't quite understandable to react and it throws a warning
Warning: <Comp0 /> is using uppercase HTML. Always use lowercase HTML
tags in React.
If you were to create a custom component element with React.createElement, you have to pass the direct class/function, instead of its name (that's only for DOM elements), to it, e.g. React.createElement(Shoot0) instead of React.createElement('Shoot0');
You can circumvent the issue by putting the components you intend for in array and index them
const Shoot0 = () => <div>1</div>;
const Shoot1 = () => <div>2</div>;
const Shoot2 = () => <div>3</div>;
const Shoots = [Shoot0, Shoot1, Shoot2];
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
activeComponent: 0
};
}
componentDidMount() {
setInterval(() => {
this.setState((prevState) => {
return {
activeComponent: (prevState.activeComponent + 1) % 3
}
})
}, 1000)
}
render() {
return React.createElement(Shoots[this.state.activeComponent])
}
}
ReactDOM.render(<App />, document.getElementById('app'))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.1.0/react-dom.min.js"></script>
<div id="app"></div>
You can just do a function with a mapping like this:
const stateArray = [Comp0, Comp1, Comp2];
const getComp = (Comp) => <Comp>
const getCompFromArray = (i) => getComp(stateArray[i]);

Using branch from recompose

I am refactoring a stateless functional component to use branch and renderComponent from recompose.
The original component looks like this:
const Icon = props => {
const { type, name } = props
let res
if (type === 'font') {
return (<FontIcon name={name} />)
} else if (type === 'svg') {
res = (<SvgIcon glyph={name} />)
}
return res
}
The component with branch looks like this:
const isFont = props => {
const { type } = props
return type === 'font'
}
const FontIconHoC = renderComponent(FontIcon)
const SvgIconHoC = renderComponent(SvgIcon)
const Icon = branch(isFont, FontIconHoC, SvgIconHoC)
Icon.propTypes = {
type: string,
name: string
}
export default Icon
I try and render the component using:
<Icon name='crosshairs' type='font' />
The resulting error looks like this:
invariant.js:44Uncaught Error: Icon(...): A valid React element (or null) must be returned. You may have returned undefined, an array or some other invalid object.
branch returns a HOC, which accepts a component and return a component, so branch(...) is a HOC and branch(...)(...) is a component.
In your case, because Icon is not a component but a HOC, so React can't render it. To fix it, you can move SvgIcon out from branch's arguments and apply it to the HOC returned by branch(...), ex:
const Icon = branch(
isFont,
FontIconHoC,
a => a
)(SvgIcon)
We apply an identity function (a => a) to the third argument of branch. You can think of the identity function is also a HOC, which basically just return the component it gets and does nothing more.
Because this pattern is used very often, so the third argument of branch is default to the identity function. As a result, we can omit it and make our code simpler:
const Icon = branch(
isFont,
FontIconHoC
)(SvgIcon)
I've created a jsfiddle for these code. You can try it here.
You can also just use an if statement instead of branch. Consider that you just had some difficulties doing what an if statement does.
Maybe time to reconsider that library ?

Categories

Resources