Найти в Дзене
IlyaDev

Автоскролл в чате. React.

Привет друг! В этой статье попробую описать, один из вариантов автоматического скролла к последнему, приходящему сообщению в чате. В React приложении. Минута саморекламы😇: Хочешь порядка в коде, тебе сюда https://dzen.ru/a/Z1iL1J62BiwFEYgd. Ну а теперь, поехали!🛼 Как болванку буду использовать собранный проект при помощи vite: import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vi
Оглавление

Привет друг! В этой статье попробую описать, один из вариантов автоматического скролла к последнему, приходящему сообщению в чате. В React приложении.

Минута саморекламы😇:

Хочешь порядка в коде, тебе сюда https://dzen.ru/a/Z1iL1J62BiwFEYgd.

Ну а теперь, поехали!🛼

1. Создаём болванку

Как болванку буду использовать собранный проект при помощи vite:

import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'

function App() {
const [count, setCount] = useState(0)

return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}

</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}


export default App

2. Создаём компоненты

Создадим две компоненты Display.tsx и MessageForm.tsx и немного подготовим App.tsx, на MessageForm.tsx и App.tsx подробно останавливаться не буду, статья не об этом. Круто отмазался от того, что мне лень ?😇

App.tsx:

import {useState} from "react";

import {Display} from "./display/Display.tsx";
import {MessageForm} from "./messageForm/MessageForm.tsx";

import s from './App.module.css';

export type MessageType = {
id: string,
title: string
};

export const App = () => {

const [messages, setMessages] = useState<MessageType[]>([{id:'1', title: 'hello friend'}]);

const setMessageHandler = (newMessageTitle: string) => {
const newMessage = {
id: `${Date.now()}`,
title: newMessageTitle,
};
setMessages([...messages, newMessage]);
};

return (
<div className={s.appContainer}>
<Display messages={messages}/>
<MessageForm setMessageHandler={setMessageHandler}/>
</div>
)
};
.appContainer {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
min-height: 100vh;
min-width: 100vh;
gap: 5px;
}
  • messages - состояние для хранения сообщений.
  • setMessageHandler - функция для добавления сообщения.

MessageForm.tsx:

import {ChangeEvent, FC, useState} from "react";

import s from './MessageForm.module.css'

interface IMessageForm {
setMessageHandler: (newMessageTitle: string) => void;
}

export const MessageForm: FC<IMessageForm> = ({ setMessageHandler }) => {
const [inputValue, setInputValue] = useState<string>('');

const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};

const onAddMessage = () => {
if (inputValue.trim()) {
setMessageHandler(inputValue);
setInputValue("");
}
};

return (
<div className={s.messageFormContainer}>
<input type="text" value={inputValue} onChange={onInputChange} />
<button onClick={onAddMessage}>add message</button>
</div>
);
};

Display.tsx

import {FC} from "react";

import {MessageType} from "../App.tsx";

import s from './Display.module.css';

interface IDisplay {
messages: MessageType[]
}

export const Display: FC<IDisplay> = ({messages}) => {

return (
<div className={s.displayContainer}>
{messages.map(message => (
<div key={message.id} className={s.message}>
{message.title}
</div>
))}
</div>
);
};
.displayContainer {
display: flex;
flex-direction: column;
min-width: 100vh;
border: 1px solid black;
border-radius: 25px;
padding: 10px;
background-color: #f9f9f9;

.message {
margin-bottom: 5px;
padding: 5px;
border-radius: 5px;
background-color: #f0f0f0;
}
}

3. Пишем логику для автоскролла

И так всё волшебство будет проходить в Display.tsx.

-2

Для достижения сего действия нам нужен доступ к DOM элементу для взаимодействия с ним (включая специфические свойства и методы DOM-элемента), в нашем случаем родительская дивка компоненты DIsplay.tsx, а значит ref - в студию. Ну и парочку состояний, позицию вертикального скролла в момент прокрутки и флажок для определения того, скролим, мы в итоге или где?.

const containerRef = useRef<HTMLDivElement>(null);
const [lastScrollTop, setLastScrollTop] = useState<number>(0);
const [isAutoScroll, setIsAutoScroll] = useState<boolean>(true);

Создадим функцию scrollEventHandler которая будет:

  • Вычислять текущую позицию прокрутки (scrollTop).
  • Определять, идет ли прокрутка вниз и находится ли пользователь внизу контейнера.
  • Включать или отключать авто-прокрутку (isAutoScroll).
  • Обновлять предыдущее положение прокрутки (lastScrollTop) для следующего вызова.
const scrollEventHandler = (event: React.UIEvent<HTMLDivElement, UIEvent>) => {
const element = event.currentTarget;
const maxScrollPosition = element.scrollHeight - element.clientHeight;

if (element.scrollTop > lastScrollTop && Math.abs(maxScrollPosition - element.scrollTop) < 10) {
setIsAutoScroll(true);
} else {
setIsAutoScroll(false);
}
setLastScrollTop(element.scrollTop);
};
  • const element = event.currentTarget;

event.currentTarget — это элемент, на котором произошло событие (в данном случае контейнер div с рефом containerRef).

  • const maxScrollPosition = element.scrollHeight - element.clientHeight;

1. Вычисляем максимальную возможную позицию прокрутки.

2. element.scrollHeight: Полная высота контента внутри элемента (включая прокрученную и видимую части).

3. element.clientHeight: Высота видимой области элемента.

Разница между ними дает максимальную позицию, на которую можно прокрутить элемент вниз.

  • if (element.scrollTop > lastScrollTop && Math.abs(maxScrollPosition - element.scrollTop) < 10) {

1. Проверяем два условия:
1.1 element.scrollTop > lastScrollTop: Прокрутка идет вниз (текущая позиция больше предыдущей).
1.2 scrollTop: Текущее расстояние от верхнего края контейнера до верхней прокручиваемой части.

2. Math.abs(maxScrollPosition - element.scrollTop) < 10:

Контейнер находится близко к нижней границе (разница менее 10px).

Итог: Если пользователь прокручивает вниз и находится почти внизу контейнера, активируется авто-прокрутка.

  • setIsAutoScroll(true);

Устанавливаем состояние isAutoScroll в true, сигнализируя, что авто-прокрутка включена.

  • } else { setIsAutoScroll(false); }

Если хотя бы одно из условий не выполняется (например, пользователь прокручивает вверх или находится не внизу контейнера):

Устанавливаем isAutoScroll в false, сигнализируя, что авто-прокрутка отключена.

  • setLastScrollTop(element.scrollTop);

Обновляем значение lastScrollTop текущей позицией прокрутки (scrollTop).

Это необходимо для сравнения в следующем вызове функции, чтобы определить направление прокрутки (вверх или вниз).

Уже почти, ещё немного 🤩.
И так мы создали функцию для отслеживания и включения прокрутки, но чего то не хватает отслеживания в реальном времени прокручивать или нет состояние
isAutoScroll, а так же добавления нового сообщения messages. useEffect скадыщь🪄! Иными словами, если isAutoScroll изменилось → useEffect выполняется:

useEffect(() => {
if (isAutoScroll && containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [isAutoScroll, messages]);

Обновление isAutoScroll или lastScrollTop вызывает повторный рендер.

useEffect срабатывает при изменении зависимости isAutoScroll или messages:

  • Если isAutoScroll стало true: Контейнер автоматически прокручивается вниз (до конца) через scrollTo.
  • Если isAutoScroll стало false, useEffect не выполняет никаких действий, так как условие if (isAutoScroll && containerRef.current) не выполняется.
  • useEffect срабатывает при изменении зависимости messages:

Если isAutoScroll === true, контейнер автоматически прокручивается вниз до последнего сообщения.
Если
isAutoScroll === false, прокрутка не происходит, так как пользователь уже отключил авто-прокрутку, взаимодействуя с контейнером вручную.

  • scrollEventHandler не срабатывает:

Это происходит, потому что добавление нового сообщения само по себе не вызывает события прокрутки. Событие прокрутки срабатывает только при взаимодействии пользователя.

Хорошо бы не забыть про стили:
Display.module.css:

.displayContainer {
display: flex;
flex-direction: column;
height: 300px;
min-width: 100vh;
/* Включен скролл */
overflow-y: scroll;

border: 1px solid black;
border-radius: 25px;
padding: 10px;
background-color: #f9f9f9;
/* Скрытие полосы прокрутки в Firefox */
scrollbar-width: none;

.message {
margin-bottom: 5px;
padding: 5px;
border-radius: 5px;
background-color: #f0f0f0;
}
}
/* Скрытие полосы прокрутки (Chrome, Safari, Edge) */
.displayContainer::-webkit-scrollbar {
display: none;
}

Итого, если двумя словами разумеется:

  1. Пользователь прокручивает контейнер → scrollEventHandler срабатывает: Определяет, находится ли пользователь внизу и прокручивает ли он вниз.
    Обновляет состояние
    isAutoScroll.
  2. Если isAutoScroll изменилось → useEffect выполняется: Прокручивает контейнер вниз, если включена авто-прокрутка.
  3. Если приходит новое сообщение → useEffect выполняется: Прокручивает контейнер вниз, только если isAutoScroll === true.

Таким образом, функция scrollEventHandler срабатывает только на событие прокрутки, а useEffect срабатывает на изменение зависимостей (isAutoScroll или messages).

-3

4. Итоговый код

Итоговый Display.tsx:

import {FC, useEffect, useRef, useState} from "react";

import {MessageType} from "../App.tsx";

import s from './Display.module.css';

interface IDisplay {
messages: MessageType[]
}

export const Display: FC<IDisplay> = ({messages}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [lastScrollTop, setLastScrollTop] = useState<number>(0);
const [isAutoScroll, setIsAutoScroll] = useState<boolean>(true);

const scrollEventHandler = (event:
React.UIEvent<HTMLDivElement, UIEvent>) => {
const element = event.currentTarget;
const maxScrollPosition = element.scrollHeight - element.clientHeight;

if (element.scrollTop > lastScrollTop &&
Math.abs(maxScrollPosition - element.scrollTop) < 10) {
setIsAutoScroll(true);
} else {
setIsAutoScroll(false);
}
setLastScrollTop(element.scrollTop);
};

useEffect(() => {
if (isAutoScroll && containerRef.current) {
containerRef.current.scrollTo({
top: containerRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [isAutoScroll, messages]);

return (
<div ref={containerRef} className={s.displayContainer} onScroll={scrollEventHandler}>
{messages.map(message => (
<div key={message.id} className={s.message}>
{message.title}
</div>
))}
</div>
);
};
.displayContainer {
display: flex;
flex-direction: column;
height: 300px;
min-width: 100vh;
overflow-y: scroll;
border: 1px solid black;
border-radius: 25px;
padding: 10px;
background-color: #f9f9f9;
scrollbar-width: none;

.message {
margin-bottom: 5px;
padding: 5px;
border-radius: 5px;
background-color: #f0f0f0;
}
}

.displayContainer::-webkit-scrollbar {
display: none;
}

Ну вот собственно и всё 🚀🚀🚀.

Спасибо за внимание! До Новых Встреч!🤗🤗🤗