React Hooks - Passing ref to child to use ScrollIntoView - reactjs

I have two components. A parent and a child.
Inside the parent component I have a button. If the user clicks on that button I want to do a ScrollIntoView to another button inside the child component.
I guess I want to define a reference to the childs button a so that I inside the parent button onClick can do a:
ref.scrollIntoView({block: 'end', behavior: 'smooth'});
that will scroll to the button in the child component.
Here is a minified example:
ParentComponent.jsx
import React, {useRef} from 'react';
import ChildComponent from './ChildComponent';
const ParentComponent = props => {
const childReference = useRef(null);
const onClick = () => {
childReference.scrollIntoView({block: 'end', behavior: 'smooth'});
}
return (
<>
<...some other components>
<Button onClick={onClick}>Click me to be forwarded</Button>
<ChildComponent ref={childReference}/>
</>
);
};
ChildComponent.jsx
import React from 'react';
const ChildComponent = (props, ref) => {
const { name, value, description } = props;
return (
<...some other components>
<Button ref={ref}>You should be forwarded to me</Button>
);
};
ChildComponent.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.number,
description: PropTypes.string,
};
ChildComponent.defaultProps = {
value: 0,
description: '',
};
export default React.forwardRef(ChildComponent);
I know the above code doesn't work, it was just to illustrate what I am trying to achieve.
I have really tried every other solution I have been able to find by Googling and they all seem so easy, but none of them seem to work for my use case. I have tried using forwardRef as well, but that also doesn't fix it for me.
UPDATE
I guess I was a little vague on what's not working. I've been getting a lot of different error messages with the implementation.
The following is one of them:
Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
Solution
Okay. I thought I'd assemble the pieces here with the solution provided by #Vencovsky.
This is the full implementation with the two example components seen in the question:
ParentComponent.jsx
import React, { useRef } from 'react';
import ChildComponent from './ChildComponent';
const ParentComponent = props => {
const childReference = useRef(null);
const scrollIntoView = () => {
childReference.current.scrollIntoView({block: 'center', inline: 'center', behavior: 'smooth'});
}
return (
<>
<...some other component>
<Button onClick={scrollIntoView}>Click me to be forwarded</Button>
<ChildComponent ref={childReference}
</>
);
};
export default ParentComponent;
ChildComponent.jsx
import React, {forwardRef} from 'react';
import PropTypes from 'prop-types';
const ChildComponent = forwardRef((props, ref) => {
const { name, value, description } = props;
return(
<>
<...some other components>
<Button ref={ref}>You should be forwarded to me</Button>
</>
);
});
ChildComponent.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.number,
description: PropTypes.string,
};
ChildComponent.defaultProps = {
value: 0,
description: '',
};
export default ChildComponent;

Edit 2:
I guess you are doing something like
const ChildComponent = (props, ref) => { ... }
ChildComponent.propTypes = { ... }
export default React.forwardRef(ChildComponent)
But what you need to do is pass propTypes after React.forwardRef, like so:
const ChildComponent = (props, ref) => { ... }
const ForwardComponent = React.forwardRef(ChildComponent)
ForwardComponent.propTypes = { ... }
export default ForwardComponent
A better way to do it would be like
// using forwarRef
const ChildComponent = React.forwarRef((props, ref) => {
const { name, value, description } = props;
return (
<...some other components>
<Button ref={ref}>You should be forwarded to me</Button>
);
});
Then you wouldn't need to change propTypes and create another component.
Edit:
As your Edit, I can see that you forgot to use React.forwardRef.
You should add
export default React.forwardRef(ChildComponent)
To your ChildComponent file (export it with forwardRef).
What is not working? Are you getting an error? You should explain better what is going on, but I will try to guess.
There is somethings that can make it not work.
You need to use ref.current.foo instead of ref.foo
As #JeroenWienk said:
It seems that your button is also a custom component. Are you sure the ref is being passed to the html button element inside there?
To use the second parameter of an functional component, you should be using React.forwardRef. e.g. export default React.forwardRef(ChildComponent)

Related

Define a functional component inside storybook preview

I have a custom modal component as functional component and in typescript. This modal component exposes api's through context providers and to access them, I'm using useContext hook.
const { openModal, closeModal } = useContext(ModalContext);
Example code on how I use this api's:
const TestComponent = () => {
const { openModal, closeModal } = useContext(ModalContext);
const modalProps = {}; //define some props
const open = () => {
openModal({...modalProps});
}
return (
<div>
<Button onClick={open}>Open Modal</Button>
</div>
)
}
And I wrap the component inside my ModalManager
<ModalManager>
<TestComponent />
</ModalManager>
This example works absolutely fine in my Modal.stories.tsx
Problem:
But this doesn't work inside my Modal.mdx. It says I cannot access react hooks outside functional component. So, I need to define a TestComponent like component to access my modal api's from context. How to define it and where to define it so that below code for preview works?
import {
Props, Preview, Meta
} from '#storybook/addon-docs/blocks';
<Meta title='Modal' />
<Preview
isExpanded
mdxSource={`
/* source of the component like in stories.tsx */
`}
>
<ModalManager><TestComponent /></ModalManager>
</Preview>
I'm not sure if this is a hack or the only way. I created the TestComponent in different tsx file and then imported it in mdx. It worked.
You may have a utility HOC to render it inside a MDX file as below
HOCComp.tsx in some Utils folder
import React, { FunctionComponent, PropsWithChildren } from 'react';
export interface HOCCompProps {
render(): React.ReactElement;
}
const HOCComp: FunctionComponent<HOCCompProps> = (props: PropsWithChildren<HOCCompProps>) => {
const { render } = props;
return render();
};
export default HOCComp;
Inside MDX File
import HOCComp from './HOC';
<HOCComp render={()=> {
function HOCImpl(){
const [count,setCount] = React.useState(180);
React.useEffect(() => {
const intId = setInterval(() => {
const newCount = count+1;
setCount(newCount);
},1000)
return () => {
clearInterval(intId);
}
})
return <Text>{count}</Text>
}
return <HOCImpl />
}}
/>

On click returns null instead of an object

It's really basic I guess. I'm trying to add onClick callback to my script & I believe I'm missing a value that would be responsible for finding the actual item.
Main script
import React from 'react';
import { CSVLink } from 'react-csv';
import { data } from 'constants/data';
import GetAppIcon from '#material-ui/icons/GetApp';
import PropTypes from 'prop-types';
const handleClick = (callback) => {
callback(callback);
};
const DownloadData = (props) => {
const { callback } = props;
return (
<>
<CSVLink
data={data}
onClick={() => handleClick(callback)}
>
<GetAppIcon />
</CSVLink>
</>
);
};
DownloadData.propTypes = {
callback: PropTypes.func.isRequired,
};
export default DownloadData;
Storybook code
import React from 'react';
import DownloadData from 'common/components/DownloadData';
import { data } from 'constants/data';
import { action } from '#storybook/addon-actions';
export default {
title: 'DownloadData',
component: DownloadData,
};
export const download = () => (
<DownloadData
data={data}
callback={action('icon-clicked')}
/>
);
So right now with this code on click in the storybook I'd get null and I'm looking for an object.
One of the potential issues I can see is that your handleClick function is stored as it is in-memory, when you import the component. That means you're keeping reference of something that doesn't exists and expects to use it when rendering the component with the callback prop.
Each instance of a component should have its own function. To fix it, move the function declaration inside the component. Like this:
const Foo = ({ callback }) => {
// handleClick needs to be inside here
const handleClick = callback => {
console.log("clicked");
callback(callback);
};
return <div onClick={() => handleClick(callback)}>Click me!</div>;
};
Check this example.
If this doesn't fix your problem, then there is something wrong with how you're implementing Storybook. Like a missing context.

How to use refs in react through react-redux , withRouter?

I'm trying to use ref in a component connected to react-redux
I've tried this solution.
connect(null, null, null, {forwardRef: true})(myCom)
<myCom ref={ref => this.myCom = ref} />
this works just fine according to react-redux docs, but now when i try using
withRouter at the same time i get an error:
Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
so the final export code i have tried which lead to the above error
export default connect(null, null, null, { forwardRef: true })(withRouter(withStyles(styles)(myCom)));
Note: withStyles doesn't cause any issues as i've tried removing only withRouter, the issue was resolved.
is there any solution to solve this issue ?
In order to pass a ref to a component wrapped by withRouter you need to call it wrappedComponentRef. I recommend having withRouter as the outermost wrapper, so your example would look like the following:
withRouter(connect(null, null, null, {forwardRef: true})(myCom));
<myCom wrappedComponentRef={ref => this.myCom = ref} />
The following example and description are adapted from a related answer of mine: Get ref from connected redux component withStyles
Below is code from a modified version of the react-redux todo list tutorial that shows the correct syntax. I've included here the two files that I changed (TodoList.js and TodoApp.js), but the sandbox is a fully working example.
In TodoApp, I use the ref (via the wrappedComponentRef property) on TodoList to get and display its height. The displayed height will only get updated if TodoApp re-renders, so I've included a button to trigger a re-render. If you add a couple todos to the todo list, and then click the re-render button, you will see that the new height of the list is displayed (showing that the ref is fully working).
In TodoList, I'm using withStyles to add a blue border around the todo list to show that withStyles is working, and I'm displaying the primary color from the theme to show that withTheme is working. I am also displaying the location object from withRouter to demonstrate that withRouter is working.
TodoList.js
import React from "react";
import { connect } from "react-redux";
import Todo from "./Todo";
import { getTodosByVisibilityFilter } from "../redux/selectors";
import { withStyles, withTheme } from "#material-ui/core/styles";
import clsx from "clsx";
import { withRouter } from "react-router-dom";
const styles = {
list: {
border: "1px solid blue"
}
};
const TodoList = React.forwardRef(
({ todos, theme, classes, location }, ref) => (
<>
<div>Location (from withRouter): {JSON.stringify(location)}</div>
<div>theme.palette.primary.main: {theme.palette.primary.main}</div>
<ul ref={ref} className={clsx("todo-list", classes.list)}>
{todos && todos.length
? todos.map((todo, index) => {
return <Todo key={`todo-${todo.id}`} todo={todo} />;
})
: "No todos, yay!"}
</ul>
</>
)
);
const mapStateToProps = state => {
const { visibilityFilter } = state;
const todos = getTodosByVisibilityFilter(state, visibilityFilter);
return { todos };
};
export default withRouter(
connect(
mapStateToProps,
null,
null,
{ forwardRef: true }
)(withTheme(withStyles(styles)(TodoList)))
);
TodoApp.js
import React from "react";
import AddTodo from "./components/AddTodo";
import TodoList from "./components/TodoList";
import VisibilityFilters from "./components/VisibilityFilters";
import "./styles.css";
export default function TodoApp() {
const [renderIndex, incrementRenderIndex] = React.useReducer(
prevRenderIndex => prevRenderIndex + 1,
0
);
const todoListRef = React.useRef();
const heightDisplayRef = React.useRef();
React.useEffect(() => {
if (todoListRef.current && heightDisplayRef.current) {
heightDisplayRef.current.innerHTML = ` (height: ${
todoListRef.current.offsetHeight
})`;
}
});
return (
<div className="todo-app">
<h1>
Todo List
<span ref={heightDisplayRef} />
</h1>
<AddTodo />
<TodoList wrappedComponentRef={todoListRef} />
<VisibilityFilters />
<button onClick={incrementRenderIndex}>
Trigger re-render of TodoApp
</button>
<div>Render Index: {renderIndex}</div>
</div>
);
}
Use compose method and try something like this
const enhance = compose(
withStyles(styles),
withRouter,
connect(mapStateToProps, null, null, { forwardRef: true })
)
and use it before exporting component
export default enhance(MyComponent)
You can do it this way! This will work for sureπŸ‘
import { withRouter } from 'react-router';
//Just copy and add this withRouterAndRef HOC
const withRouterAndRef = (WrappedComponent) => {
class InnerComponentWithRef extends React.Component {
render() {
const { forwardRef, ...rest } = this.props;
return <WrappedComponent {...rest} ref={forwardRef} />;
}
}
const ComponentWithRouter = withRouter(InnerComponentWithRef, { withRef: true });
return React.forwardRef((props, ref) => {
return <ComponentWithRouter {...props} forwardRef={ref} />;
});
}
class MyComponent extends Component {
constructor(props) {
super(props);
}
}
//export using withRouterAndRef
export default withRouterAndRef (MyComponent)

How to change React component from outside?

The React component, which can be considered as third-party component, looks as following:
import * as React from 'react';
import classnames from 'classnames';
import { extractCommonClassNames } from '../../utils';
export const Tag = (props: React.ElementConfig): React$Node =>{
const{
classNames,
props:
{
children,
className,
...restProps
},
} = extractCommonClassNames(props);
const combinedClassNames = classnames(
'tag',
className,
...classNames,
);
return (
<span
className={combinedClassNames}
{...restProps}
>
{children}
<i className="sbicon-times txt-gray" />
</span>
);
};
The component where I use the component above looks as following:
import React from 'react';
import * as L from '#korus/leda';
import type { KendoEvent } from '../../../types/general';
type Props = {
visible: boolean
};
type State = {
dropDownSelectData: Array<string>,
dropDownSelectFilter: string
}
export class ApplicationSearch extends React.Component<Props, State> {
constructor(props) {
super(props);
this.state = {
dropDownSelectData: ['Имя', 'Ѐамилия', 'Машина'],
dropDownSelectFilter: '',
};
this.onDropDownSelectFilterChange = this.onDropDownSelectFilterChange.bind(this);
}
componentDidMount() {
document.querySelector('.sbicon-times.txt-gray').classList.remove('txt-gray');
}
onDropDownSelectFilterChange(event: KendoEvent) {
const data = this.state.dropDownSelectData;
const filter = event.filter.value;
this.setState({
dropDownSelectData: this.filterDropDownSelectData(data, filter),
dropDownSelectFilter: filter,
});
}
// eslint-disable-next-line class-methods-use-this
filterDropDownSelectData(data, filter) {
// eslint-disable-next-line func-names
return data.filter(element => element.toLowerCase().indexOf(filter.toLowerCase()) > -1);
}
render() {
const {
visible,
} = this.props;
const {
dropDownSelectData,
dropDownSelectFilter,
} = this.state;
return (
<React.Fragment>
{
visible && (
<React.Fragment>
<L.Block search active inner>
<L.Block inner>
<L.Block tags>
<L.Tag>
option 1
</L.Tag>
<L.Tag>
option 2
</L.Tag>
<L.Tag>
...
</L.Tag>
</L.Block>
</L.Block>
</React.Fragment>
)}
</React.Fragment>
);
}
}
Is it possible to remove "txt-gray" from the component from outside and if so, how?
Remove the class from where you're using the Tag component:
componentDidMount() {
document.querySelector('.sbicon-times.txt-gray').classList.remove('txt-gray')
}
Or more specific:
.querySelector('span i.sbicon-times.txt-gray')
As per your comment,
I have multiple components with "txt-gray", but when I use your code, "txt-gray" has been removed from first component only. How to remove it from all components?
I will suggest you to use the code to remove the class in the parent component of using the Tag component. And also use querySelectorAll as in this post.
Refactoring
A clean way is to modify the component to allow it to conditionally add txt-gray through a prop:
<i className={classnames('sbicon-times', { 'txt-gray': props.gray })} />
If the component cannot be modified because it belongs to third-party library, this involves forking a library or replacing third-party component with its modified copy.
Direct DOM access with findDOMNode
A workaround is to access DOM directly in parent component:
class TagWithoutGray extends React.Component {
componentDidMount() {
ReactDOM.findDOMNode(this).querySelector('i.sbicon-times.txt-gray')
.classList.remove('txt-gray');
}
// unnecessary for this particular component
componentDidUpdate = componentDidMount;
render() {
return <Tag {...this.props}/>;
}
}
The use of findDOMNode is generally discouraged because direct DOM access is not idiomatic to React, it has performance issues and isn't compatible with server-side rendering.
Component patching with cloneElement
Another workaround is to patch a component. Since Tag is function component, it can be called directly to access and modify its children:
const TagWithoutGray = props => {
const span = Tag(props);
const spanChildren = [...span.props.children];
const i = spanChildren.pop();
return React.cloneElement(span, {
...props,
children: [
...spanChildren,
React.cloneElement(i, {
...i.props,
className: i.props.className.replace('txt-gray', '')
})
]
});
}
This is considered a hack because wrapper component should be aware of patched component implementation, it may break if the implementation changes.
No, it is not possible
The only way is to change your Tag component
import * as React from 'react';
import classnames from 'classnames';
import { extractCommonClassNames } from '../../utils';
export const Tag = (props: React.ElementConfig): React$Node =>{
const{
classNames,
props:
{
children,
className,
...restProps
},
} = extractCommonClassNames(props);
const combinedClassNames = classnames(
'tag',
className,
...classNames,
);
const grayClass = this.props.disableGray ? 'sbicon-times' : 'sbicon-times txt-gray';
return (
<span
className={combinedClassNames}
{...restProps}
>
{children}
<i className={grayClass} />
</span>
);
};
Now, if you pass disableGray={true} it will suppress the gray class, otherwise of you pass false or avoid passing that prop at all it will use the gray class. It is a small change in the component, but it allows you not to change all the points in your code where you use this component (and you are happy with grey text)

React + Jest check component contains another component

I have DetailPanel component which contains Detail componet with title prop.
import React from 'react';
import Detail from './Detail';
import InfoTable from './InfoTable';
import EdgeIcon from './EdgeIcon';
import styles from './DetailsPanel.css';
export default class DetailsPanel extends React.PureComponent {
renderTitle() {
const { selectedEdge } = this.props;
if (selectedEdge) {
const sourceNode = 'sourceNode'
const targetNode = 'targetNode'
return (
<span>
{sourceNode}
<EdgeIcon className={styles.arrowIcon} />
{targetNode}
</span>
);
}
return 'default title';
}
render() {
return (
<Detail title={this.renderTitle()} />
);
}
}
I tying to check that if selectedEdge is true Detail title contains EdgeIcon component
test('Detail should contain EdgeIcon if there is selected edge', () => {
const selectedEdge = { source: 1, target: 2 };
const detailsPanel = shallow(
<DetailsPanel
{...props}
selectedEdge={selectedEdge}
/>);
expect(detailsPanel.contains('EdgeIcon')).toBeTruthy();
});
But test fails, because detailsPanel returns false
You can try to use mount instead of shallow if you want to check deep children. For example:
test('Detail should contain EdgeIcon if there is selected edge', () => {
const selectedEdge = { source: 1, target: 2 };
const detailsPanel = mount(
<DetailsPanel
{...props}
selectedEdge={selectedEdge}
/>);
expect(detailsPanel.contains('EdgeIcon')).toBeTruthy();
});
First, when testing a component, you shouldn't test its children. Each children should have its own test.
But if you really need to look into your component's children use mount instead of shallow.
Reference: http://airbnb.io/enzyme/docs/api/ReactWrapper/mount.html

Resources