dmikester1
dmikester1

Reputation: 1362

React component is scrolling back to top on re-render

I have this issue with my React component I cannot figure out. It scrolls back to the top each time it re-renders. And also, I can't figure out why it is re-rendering in the first place. Basically I have a grandparent, parent, and grandchild component. The grandchild has a click event that displays a different child of the grandparent, if that makes any sense. That click event does a few things, but it is causing a re-render of the grandparent which causes the parent component to scroll to the top. I will add in my code which will hopefully clear up what I'm saying here. Just wanted to give some context.

So the main issue is the scrolling to the top. If we can figure out why it is re-rendering and stop that, that is a bonus.

Grandparent (StaticLine.js):

import React, { useEffect, useState } from 'react';
import StaticOrderColumn from '../ordercolumn/StaticOrderColumn';
import { useGlobalLineTypeActionsContext } from '../../context/GlobalLineTypeContext';
import Details from '../details/DetailsColumn';
import { ModalStyles } from '../modals/ModalStyles';
import {
    useScheduleActionsContext,
    useScheduleContext
} from '../../context/ScheduleContext';
import PalletCount from './PalletCount';
import OrderButtons from './OrderButtons';
import PropTypes from 'prop-types';
import {
    useGlobalShowDetailsActionsContext,
    useGlobalShowDetailsContext
} from '../../context/GlobalShowDetailsContext';
import { useTraceUpdate } from '../../utils/hooks';

const StaticLine = (props) => {
    useTraceUpdate(props);
    console.log('top of StaticLine');
    const { type, setTitle } = props;

    const { orderType, orders } = useScheduleContext();
    const { setOrders, setOrderType } = useScheduleActionsContext();
    const setGlobalLineType = useGlobalLineTypeActionsContext();
    const showDetails = useGlobalShowDetailsContext();
    const setShowDetails = useGlobalShowDetailsActionsContext();

    const [lineID, setLineID] = useState(-1);
    const [lineTitle, setLineTitle] = useState('');
    const [highlightDest, setHighlightDest] = useState(false);

    const ordersCol = React.useRef();

    let lineCountOffset = 0;
    let numLines = 4;
    let pageTitle = '';
    switch (type) {
        case 'bagline':
            pageTitle += 'Bag Lines';
            break;
        case 'toteline':
            pageTitle += 'Tote Lines';
            lineCountOffset = 10;
            break;
        case 'otherline':
            pageTitle += 'Other Lines';
            numLines = 3;
            lineCountOffset = 4;
            break;
        default:
    }

    useEffect(() => {
        setLineID(-1);
    }, [type]);

    const globalLineType = type + 's';
    useEffect(() => {
        setGlobalLineType(globalLineType);
        setTitle(pageTitle);
        const title = `${process.env.REACT_APP_BASE_TITLE ||
            'title'} - ${pageTitle}`;
        document.title = title;
    }, [type, setGlobalLineType, pageTitle, setTitle, globalLineType]);

    const selectLine = (e) => {
        setShowDetails(false);
        setHighlightDest(false);
        const lineNum = e.target.value.substring(4);
        setLineID(parseInt(lineNum, 10));
        setLineTitle(
            orderType.charAt(0).toUpperCase() +
                orderType.slice(1) +
                ' Orders - Line ' +
                parseInt(lineNum, 10)
        );
    };

    const selectOrderType = (e) => {
        const selectedOrderType = e.target.value;
        setOrderType(selectedOrderType);
        setShowDetails(false);
        setLineTitle(
            selectedOrderType.charAt(0).toUpperCase() +
                selectedOrderType.slice(1) +
                ' Orders - Line ' +
                lineID
        );
    };

    const OrderColWithRef = React.forwardRef((props, ref) => (
        <StaticOrderColumn
            {...props}
            title={lineTitle}
            lineID={lineID}
            orders={orders}
            ref={ref}
        />
    ));

    return (
        <div
            className={`staticLines p-1 no-gutters d-flex flex-nowrap${
                orderType === 'completed' ? ' completed' : ''
            }`}
        >
            <div className={'radio-col no-border'}>
                <div className={'radio-container p-2'}>
                    <div className={'radios'}>
                        // lots of irrelevant code here
                    </div>
                </div>
            </div>
            {lineID > -1 && (
                <>
                    <div
                        className={'col lines no-gutters order-col'}
                    >
                        <OrderColWithRef ref={ordersCol} />
                    </div>
                    <div className={'col row lines no-gutters order-details'}>
                        <Details
                            setOrders={setOrders}
                            orders={orders || []}
                            customStyles={ModalStyles}
                            highlightDest={highlightDest}
                            setHighlightDest={setHighlightDest}
                            errLocation={'top-center'}
                        />
                        {orderType === 'completed' && showDetails && (
                            <OrderButtons
                                setLineID={setLineID}
                                setOrders={setOrders}
                                orders
                                lineNum={lineID}
                            />
                        )}
                    </div>

                    <div className={'col lines no-gutters d-flex no-border'}>
                        {orderType === 'scheduled' && (
                            <PalletCount
                                type={'Bag'}
                                lineNum={lineID}
                                orders={orders}
                                setTitle={setTitle}
                                setHighlightDest={setHighlightDest}
                            />
                        )}
                    </div>
                </>
            )}
        </div>
    );
};

StaticLine.propTypes = {
    type: PropTypes.string.isRequired,
    orders: PropTypes.array,
    setTitle: PropTypes.func.isRequired
};

export default StaticLine;

Parent (StaticOrderColumn.js):

import React from 'react';
import PropTypes from 'prop-types';
import StaticOrder from '../order/StaticOrder';
import '../../scss/App.scss';
import { useGlobalSpinnerContext } from '../../context/GlobalSpinnerContext';

const StaticOrderColumn = (props) => {
    const { title, lineID, orders } = props;

    const isGlobalSpinnerOn = useGlobalSpinnerContext();

    const sortedOrdersIDs = orders
        .filter((o) => o.lineNum === lineID)
        .sort((a, b) => a.linePosition - b.linePosition)
        .map((o) => o.id);

    return (
        <div id={'line-0'} className={'col order-column'}>
            <header className={'text-center title'}>
                {title}{' '}
                {sortedOrdersIDs.length > 0 && (
                    <span> ({sortedOrdersIDs.length})</span>
                )}
            </header>
            <div className={'orders'}>
                {orders &&
                    sortedOrdersIDs &&
                    sortedOrdersIDs.map((orderID, index) => {
                        const order = orders.find((o) => o.id === orderID);
                        return (
                            <StaticOrder
                                key={orderID}
                                order={order}
                                index={index}
                            />
                        );
                    })}
                {!sortedOrdersIDs.length && !isGlobalSpinnerOn && (
                    <h3>There are no orders on this line.</h3>
                )}
            </div>
        </div>
    );
};

StaticOrderColumn.propTypes = {
    title: PropTypes.string.isRequired,
    lineID: PropTypes.number.isRequired,
    orders: PropTypes.array.isRequired,
    ref: PropTypes.instanceOf(Element).isRequired
};

export default StaticOrderColumn;

This file is where the click event happens and causes the re-render of StaticLine and scroll to top for StaticOrderColumn. Grandchild (StaticOrder.js):

import React from 'react';
import styled from 'styled-components';
import PropTypes from 'prop-types';
import '../../scss/App.scss';
import { getFormattedDate } from '../../utils/utils';
import { useGlobalActiveOrderActionsContext } from '../../context/GlobalActiveOrderContext';
import { useGlobalShowDetailsActionsContext } from '../../context/GlobalShowDetailsContext';
// import { stringTrunc } from '../../utils/utils';

const MyOrder = styled.div`
    background-color: #193df4;
    transition: background-color 1s ease;
`;

const devMode = true;
// const devMode = false;

const StaticOrder = (props) => {
    const {
        id,
        item,
        desc,
        cust,
        palletsOrd,
        bagID,
        chemicals,
        totalBagsUsed,
        linePosition,
        palletsRem,
        palletCount,
        requestDate,
        orderNumber,
        comments
    } = props.order;

    const setActiveOrder = useGlobalActiveOrderActionsContext();
    const setShowDetails = useGlobalShowDetailsActionsContext();

    const orderID = id + '';

    // show the details section when user clicks an order
    // THIS IS WHERE THE ISSUE IS HAPPENING, WHEN THE ORDER IS CLICKED,
    // THIS FUNCTION RUNS AND THE StaticLine COMPONENT RE-RENDERS AND THE StaticOrderColumn SCROLLS TO THE TOP
    const showDetails = (orderID) => {
        setActiveOrder(parseInt(orderID, 10));
        setShowDetails(true);
    };

    return (
        <MyOrder
            id={orderNumber}
            className={'order static'}
            onClick={(e) => showDetails(orderID, e)}
        >
            {/*<div className={'orderID'}>{id}</div>*/}
            <p className={'item-number'}>
                {item !== '' ? `Item Number: ${item}` : ''}
            </p>
            <p>{desc !== '' ? `NPK: ${desc}` : ''}</p>
            <p>{cust !== '' ? `Customer: ${cust}` : ''}</p>
            <p>
                {palletsOrd !== '' ? `Pallets Ordered: ${palletsOrd}` : ''}
            </p>
            <p>{bagID !== '' ? `Bag ID: ${bagID}` : ''}</p>
            <p>{chemicals !== '' ? `Chemical : ${chemicals}` : ''}</p>
            <p>
                {requestDate !== ''
                    ? `Request Date: ${getFormattedDate(new Date(requestDate))}`
                    : ''}
            </p>
            {devMode && (
                <>
                    <div className={'id-line-num-pos'}>
                        <p>OrderID: {orderNumber}</p>
                    </div>
                </>
            )}
            <div className={'total-bags'}>Total Bags: {totalBagsUsed}</div>
            <div className={'pallets-remaining'}>
                Pallets Left: {palletsRem}
            </div>
            <div className={'pallets-done'}>
                Pallets Done: {palletCount}
            </div>
            <div className={'line-position'}>{linePosition + 1}</div>
            {comments.length > 0 && (
                // bunch of SVG code
            )}
        </MyOrder>
    );
};

StaticOrder.propTypes = {
    order: PropTypes.object,
    id: PropTypes.number,
    index: PropTypes.number,
    title: PropTypes.string,
    orderID: PropTypes.string
};

export default StaticOrder;

Edit: I'm adding a picture of the problem to help you all visualize it as well. The order boxes are on the left side of this image. By default the "Order Details" is hidden. When an order on the left is clicked, it loads that order into the Order Details and shows that component. When that happens, that orders column on the left scrolls back to the top. On subsequent order clicks, it does not scroll to the top. Only when it shows or hides the "Order Details" does it scroll back to the top. Orders image

Edit 2: I figured out if I take out the const showDetails = useGlobalShowDetailsContext(); line and the 2 references to showDetails out of StaticLine.js, this issue goes away. So if that helps anyone figure something out...

Edit 3: I'm slowly edging forward. I figured out how I could remove one of the references to showDetails in the StaticLine.js file. Now if someone could help me figure out how to get that last reference out of that component but keep the functionality that would be amazing!! For a reminder this is the reference I am talking about:

{orderType === 'completed' && showDetails && (
  <OrderButtons
    setLineID={setLineID}
    setOrders={setOrders}
    orders
    lineNum={lineID}
  />
)}

Let me know if more info or more code is needed. Any insight would be very much appreciated.

Upvotes: 12

Views: 17337

Answers (5)

ihsany
ihsany

Reputation: 1050

Had the same issue with react v18.2.0 (Typescript). None of the solutions did not work that I found on this platform or blogs. Here is my solution without using any third party, ref or setTimeout. Spend a few hours to solve, so sharing to help folks.

On my case, I need to fetch data on each seconds and update the DOM accordingly. I used local variables to store the scroll position, instead of react state, because of asynchronous behavior of the state. Below I simplified my code, removed business logic which is not relevant to the react render-scroll issue.

import React, { useEffect, useState } from 'react';

const SignalDetails = () => {
    // code block for "set scroll to the user's position"
    let posY = 0;
    let prevPosY = 0;
    let reloading = false;
    const onScroll: EventListener = (event: Event) => {
        posY = window.scrollY; // get the scroll position on-scroll
    };
    useEffect(() => {    
        if (!reloading){ // do not change event listener during the data fetch
            window.addEventListener("scroll", onScroll);
            return () => window.removeEventListener("scroll", onScroll);
        }
    }, []);

    useEffect(() => {
        const fetchData = async () => {
            prevPosY = posY;
            reloading = true; // set data loading flag true

            await getData(id).then(res => {
                if (res.Success) {
                    // after some data operations  
                    // send the scroll to the original position 
                    window.scrollTo({
                        top: prevPosY,
                        left: 0,
                        behavior: 'instant',
                    })
                }
                reloading = false; // set data loading flag false
            });
        };
        const intrv = setInterval(() => fetchData(), 1000);
        return () => clearInterval(intrv);
    }, [id]);

    // some more code here...

    return ( 
        <> 
            <div>some HTML here</div>
        </>
    );
};
export default SignalDetails;

The next day update: Deep dive into the source of the problem and identified that a chart component in my HTML code causing that unwanted scroll behavior. Some more resource bring me I need to add CSS style height to the container div of the chart, as below;

<div ref={chartContainerRef} style={{height: chartOptions.height ?? defaultHeight}} />

After adding this style to the child component, unwanted scrolling issue resolved. And, removed all those JavaScript code to set the scroll position.

On this answer, I am leaving the JavaScript code because it was working as well.

Upvotes: 0

Depicted
Depicted

Reputation: 55

Referencing off of @Mordechai accepted answer. I pulled out the component that was getting re-rendered but I realized Passing props into that seems like too much work. What I did instead was just pull out the scroll view instead.

{
...
return (
    <>
      <ScrollView>
        <ReviewLayout />
      </ScrollView>
    </>
  );
}


const ScrollView = ({children}: any)=>{
  return(
    <Box style={{width: '100%', height: 500, overflowY: 'scroll'}}>
      {children}
    </Box>
  );
}

Upvotes: 0

Max Phillips
Max Phillips

Reputation: 7489

My issue was treating the thing taking in new data as a Component and not as a function that returns a component.

Changing <NewsList /> to {newsList()} did the trick.

Upvotes: 4

Osama Sayed
Osama Sayed

Reputation: 2023

Have you tried using getSnapshotBeforeUpdate()? According to the offical documentation:

is invoked right before the most recently rendered output is committed to e.g. the DOM. It enables your component to capture some information from the DOM (e.g. scroll position) before it is potentially changed.

Basically, the idea is that you can access the current scroll position right before a re-render happens, return in from getSnapshotBeforeUpdate() and right after the component updates, using componentDidUpdate() you can access the value returned from getSnapshotBeforeUpdate() and set the scroll position to where it was before the re-rendering. Borrowing the example from the official documentation:

class ScrollingList extends React.Component {
  constructor(props) {
    super(props);
    this.listRef = React.createRef();
  }

  getSnapshotBeforeUpdate(prevProps, prevState) {
    // Are we adding new items to the list?
    // Capture the scroll position so we can adjust scroll later.
    if (prevProps.list.length < this.props.list.length) {
      const list = this.listRef.current;
      return list.scrollHeight - list.scrollTop;
    }
    return null;
  }

  componentDidUpdate(prevProps, prevState, snapshot) {
    // If we have a snapshot value, we've just added new items.
    // Adjust scroll so these new items don't push the old ones out of view.
    // (snapshot here is the value returned from getSnapshotBeforeUpdate)
    if (snapshot !== null) {
      const list = this.listRef.current;
      list.scrollTop = list.scrollHeight - snapshot;
    }
  }

  render() {
    return (
      <div ref={this.listRef}>{/* ...contents... */}</div>
    );
  }
}

I highly suggest using @welldone-software/why-did-you-render package to give you an insight into why a component renders.

Upvotes: 0

Mordechai
Mordechai

Reputation: 16214

const OrderColWithRef = React.forwardRef((props, ref) => (
        <StaticOrderColumn
            {...props}
            title={lineTitle}
            lineID={lineID}
            orders={orders}
            ref={ref}
        />
    ));

Move this outside the StaticLine as a top level function.

What's happening

React is smart enough to avoid recreating html elements and mounting stuff when only parts of the Dom changed. If only a prop changes it will preserve the element and just change its values etc. It is done by comparing the element.type.

What you are effectively doing is creating a new OrderColWithRef function on each render, since it's a local function, the types are not equal. React will unmount and remount a new html element every time anything in StaticLine changes.

Never ever nest component declarations. The only case where declaring a component inside a function would be valid would be a HOC, and even then, the HOC function itself isn't a valid element on its own, only it's return value is.

Hope this clears up things.

Upvotes: 11

Related Questions