service-worker.js
настроен. index.js
закомментируйте строку: serviceWorker.register();
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate } from 'workbox-strategies';
registerRoute(
({ url }) => url.origin === 'https://api.example.com',
new StaleWhileRevalidate()
);
try {
const response = await fetch('/api/data');
const data = await response.json();
setData(data);
} catch (error) {
setData(localStorage.getItem('offlineData'));
}
navigator.onLine
для отслеживания статуса подключения. useEffect(() => {
const updateStatus = () => {
setIsOnline(navigator.onLine);
};
window.addEventListener('online', updateStatus);
window.addEventListener('offline', updateStatus);
return () => {
window.removeEventListener('online', updateStatus);
window.removeEventListener('offline', updateStatus);
};
}, []);
useFieldArray
и Controller
станет идеальным решением. Этот подход особенно полезен, когда вы работаете с управляемыми компонентами или сторонними библиотеками UIuseFieldArray
для управления массивом данных. Controller
. import React from 'react';
import { useForm, useFieldArray, Controller } from 'react-hook-form';
function DynamicForm() {
const { control, handleSubmit } = useForm({
defaultValues: {
users: [{ name: '', age: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'users',
});
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id} style={{ marginBottom: '10px' }}>
<Controller
name={`users.${index}.name`}
control={control}
render={({ field }) => (
<input {...field} placeholder="Имя" />
)}
/>
<Controller
name={`users.${index}.age`}
control={control}
render={({ field }) => (
<input {...field} placeholder="Возраст" type="number" />
)}
/>
<button type="button" onClick={() => remove(index)}>
Удалить
</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', age: '' })}>
Добавить поле
</button>
<button type="submit">Отправить</button>
</form>
);
}
export default DynamicForm;
fields
— текущие элементы массива. append
— добавляет новый объект в массив. remove
— удаляет элемент по индексу. value
, onChange
и другие свойства с вашим компонентом. append
добавляется новый объект с полями, соответствующими вашему массиву. import { LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
const data = [
{ name: 'Янв', uv: 400 },
{ name: 'Фев', uv: 300 },
{ name: 'Март', uv: 500 },
];
const MyChart = () => (
<LineChart width={400} height={300} data={data}>
<CartesianGrid stroke="#f5f5f5" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="uv" stroke="#ff7300" />
</LineChart>
);
export default MyChart;
import { Line } from 'react-chartjs-2';
const data = {
labels: ['Янв', 'Фев', 'Март'],
datasets: [
{
label: 'Продажи',
data: [400, 300, 500],
borderColor: 'rgba(75, 192, 192, 1)',
backgroundColor: 'rgba(75, 192, 192, 0.2)',
},
],
};
const MyChart = () => <Line data={data} />;
export default MyChart;
import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
const BarChart = () => {
const chartRef = useRef();
useEffect(() => {
const data = [10, 20, 30, 40, 50];
const svg = d3
.select(chartRef.current)
.attr('width', 400)
.attr('height', 200);
svg
.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('x', (_, i) => i * 50)
.attr('y', (d) => 200 - d * 4)
.attr('width', 40)
.attr('height', (d) => d * 4)
.attr('fill', 'teal');
}, []);
return <svg ref={chartRef}></svg>;
};
export default BarChart;
onDragStart
— инициирует перетаскивание. onDragOver
— позволяет элементу быть зоной для сброса. onDrop
— срабатывает при отпускании элемента. import React, { useState } from 'react';
const DragAndDrop = () => {
const [droppedItem, setDroppedItem] = useState('');
const handleDragStart = (e, data) => {
e.dataTransfer.setData('text/plain', data);
};
const handleDrop = (e) => {
e.preventDefault();
const data = e.dataTransfer.getData('text/plain');
setDroppedItem(data);
};
const handleDragOver = (e) => {
e.preventDefault();
};
return (
<div>
<div
draggable
onDragStart={(e) => handleDragStart(e, 'Перетащено!')}
style={{ width: '150px', padding: '10px', backgroundColor: '#f0f0f0', cursor: 'grab', marginBottom: '20px' }}
>
Перетащи меня!
</div>
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
style={{ width: '200px', height: '100px', backgroundColor: '#e0e0e0' }}
>
Брось сюда!
</div>
{droppedItem && <p>Результат: {droppedItem}</p>}
</div>
);
};
export default DragAndDrop;
onDragStart
и onDrop
. npm install react-dnd react-dnd-html5-backend
import React from 'react';
import { DndProvider, useDrag, useDrop } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
const ItemType = 'BOX';
const DraggableItem = ({ id }) => {
const [, dragRef] = useDrag(() => ({
type: ItemType,
item: { id },
}));
return (
<div ref={dragRef} style={{ padding: '10px', backgroundColor: '#f0f0f0', marginBottom: '10px', cursor: 'grab' }}>
Элемент {id}
</div>
);
};
const DropZone = ({ onDrop }) => {
const [, dropRef] = useDrop(() => ({
accept: ItemType,
drop: (item) => onDrop(item.id),
}));
return (
<div
ref={dropRef}
style={{ width: '200px', height: '100px', backgroundColor: '#e0e0e0' }}
>
Брось сюда!
</div>
);
};
const DragAndDropExample = () => {
const handleDrop = (id) => {
alert(`Вы перетащили элемент ${id}`);
};
return (
<DndProvider backend={HTML5Backend}>
<DraggableItem id={1} />
<DraggableItem id={2} />
<DropZone onDrop={handleDrop} />
</DndProvider>
);
};
export default DragAndDropExample;
react-dnd
useEffect,
— это важная часть разработки, особенно в компонентах, которые взаимодействуют с сервером. Давайте разберём основные подходы и инструменты, которые помогут вам писать надёжные тесты. import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('/api/data', (req, res, ctx) => {
return res(ctx.json({ message: 'Success' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
import { render, screen, waitFor } from '@testing-library/react';
import App from './App';
test('показывает данные после успешного запроса', async () => {
render(<App />);
expect(screen.getByText(/загрузка/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/success/i)).toBeInTheDocument();
});
});
jest.fn().
const mockFetchData = jest.fn(() => Promise.resolve({ message: 'Success' }));
test('вызывает fetchData при рендере', async () => {
render(<App fetchData={mockFetchData} />);
expect(mockFetchData).toHaveBeenCalledTimes(1);
});
useEffect
часто используется для выполнения API-запросов, и важно убедиться, что компонент правильно реагирует на изменения. import { render, screen } from '@testing-library/react';
import App from './App';
test('выполняет побочный эффект при монтировании', () => {
render(<App />);
expect(screen.getByText(/загрузка/i)).toBeInTheDocument();
});
test('отменяет запрос при размонтировании', () => {
const mockFetchData = jest.fn(() => Promise.resolve());
const { unmount } = render(<App fetchData={mockFetchData} />);
unmount();
expect(mockFetchData).toHaveBeenCalledTimes(1);
});
AbortController
позволяет отменять запросы. import React, { useEffect } from 'react';
function App() {
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
fetch('https://api.example.com/posts', { signal })
.then((response) => response.json())
.then((data) => console.log(data))
.catch((err) => {
if (err.name === 'AbortError') {
console.log('Запрос отменён');
} else {
console.error('Ошибка:', err);
}
});
return () => {
controller.abort(); // Отменяем запрос при размонтировании
};
}, []);
return <h1>Отмена запросов с помощью AbortController</h1>;
}
export default App;
AbortController.
signal
в запрос fetch.
abort(),
чтобы отменить запрос. import React, { useEffect } from 'react';
import axios from 'axios';
function App() {
useEffect(() => {
const controller = new AbortController();
axios
.get('https://api.example.com/posts', {
signal: controller.signal,
})
.then((response) => console.log(response.data))
.catch((err) => {
if (axios.isCancel(err)) {
console.log('Запрос отменён');
} else {
console.error('Ошибка:', err);
}
});
return () => {
controller.abort();
};
}, []);
return <h1>Отмена запросов с Axios</h1>;
}
export default App;
AbortController,
можно обойтись флагами, хотя это менее элегантно. import React, { useEffect, useState } from 'react';
function App() {
const [data, setData] = useState(null);
useEffect(() => {
let isMounted = true;
fetch('https://api.example.com/posts')
.then((response) => response.json())
.then((data) => {
if (isMounted) {
setData(data);
}
});
return () => {
isMounted = false; // Флаг при размонтировании
};
}, []);
return <div>{data ? <h1>Данные загружены</h1> : <h1>Загрузка...</h1>}</div>;
}
export default App;
npx create-react-app my-app --template typescript
import styles from './Button.module.css';
function Button() {
return <button className={styles.primary}>Click me</button>;
}
import logo from './logo.svg';
function App() {
return <img src={logo} alt="Logo" />;
}
npm run build
создаёт готовый к деплою проект с минимизацией файлов, оптимизацией CSS, и удалением ненужного кода. .env
и укажите там переменные, например: REACT_APP_API_URL=https://api.example.com
process.env.REACT_APP_API_URL
. npm test
npm run eject
. Это сделает конфигурацию доступной для редактирования, но будьте осторожны: отменить eject
невозможно! import { counterReducer } from './counterSlice';
describe('counterReducer', () => {
it('должен увеличивать значение', () => {
const initialState = { count: 0 };
const action = { type: 'counter/increment' };
const result = counterReducer(initialState, action);
expect(result).toEqual({ count: 1 });
});
it('должен уменьшать значение', () => {
const initialState = { count: 2 };
const action = { type: 'counter/decrement' };
const result = counterReducer(initialState, action);
expect(result).toEqual({ count: 1 });
});
});
import { increment, decrement } from './counterSlice';
describe('actions', () => {
it('increment должен возвращать правильный action', () => {
expect(increment()).toEqual({ type: 'counter/increment' });
});
it('decrement должен возвращать правильный action', () => {
expect(decrement()).toEqual({ type: 'counter/decrement' });
});
});
redux-thunk
) import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fetchData } from './dataActions';
import { fetchDataSuccess } from './dataSlice';
const mockStore = configureMockStore([thunk]);
describe('async actions', () => {
it('должен диспатчить fetchDataSuccess после успешного API-запроса', async () => {
const store = mockStore({});
const mockResponse = { data: 'test' };
// Мокаем fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve(mockResponse),
})
);
await store.dispatch(fetchData());
const actions = store.getActions();
expect(actions[0]).toEqual(fetchDataSuccess(mockResponse));
});
});
Provider
с тестовым хранилищем. Provider
import React from 'react';
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import Counter from './Counter';
const mockStore = configureMockStore([]);
describe('Counter component', () => {
it('должен отображать начальное значение', () => {
const store = mockStore({ counter: { count: 0 } });
const { getByText } = render(
<Provider store={store}>
<Counter />
</Provider>
);
expect(getByText('Count: 0')).toBeInTheDocument();
});
});
jest.fn()
или библиотеки, такие как msw. ReactDOM.createPortal
. ReactDOM.createPortal(child, container)
<div id="modal-root">
. <div id="modal-root"></div>
import React from 'react';
import ReactDOM from 'react-dom';
const Modal = ({ isOpen, onClose, children }) => {
if (!isOpen) return null;
return ReactDOM.createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button className="modal-close" onClick={onClose}>×</button>
{children}
</div>
</div>,
document.getElementById('modal-root')
);
};
export default Modal;
#modal-root
. import React, { useState } from 'react';
import Modal from './Modal';
function App() {
const [isModalOpen, setModalOpen] = useState(false);
return (
<div>
<button onClick={() => setModalOpen(true)}>Открыть модальное окно</button>
<Modal isOpen={isModalOpen} onClose={() => setModalOpen(false)}>
<h1>большой привет от ReactJs Daily!</h1>
<p>Подпишись на канал)</p>
</Modal>
</div>
);
}
export default App;
npx storybook@latest init
npm run storybook
import React from 'react';
const Button = ({ label, onClick }) => (
<button onClick={onClick}>{label}</button>
);
export default Button;
import React from 'react';
import Button from './Button';
export default {
title: 'Example/Button', // Название компонента
component: Button, // Компонент
};
const Template = (args) => <Button {...args} />;
export const Primary = Template.bind({});
Primary.args = {
label: 'Primary Button',
};
export const Secondary = Template.bind({});
Secondary.args = {
label: 'Secondary Button',
};
npm install @storybook/addon-controls
npx create-next-app@latest my-nextjs-app
cd my-nextjs-app
npm run dev
pages/
— папка для маршрутов и страниц. public/
— статические файлы (изображения, шрифты и т.д.). styles/
— глобальные и модульные стили. .js
или .tsx
файл в этой папке автоматически становится маршрутом. about.js
доступен по /about
. // pages/post/[id].js
import { useRouter } from 'next/router';
export default function Post() {
const { query } = useRouter();
return <div>Post ID: {query.id}</div>;
}
logo.png
в этой папке будет доступен по /logo.png
. globals.css
) и модульные стили (Component.module.css
). pages/
соответствует URL-адресам. // pages/about.js
export default function About() {
return <h1>About Page</h1>;
}
/about
. // pages/product/[id].js
import { useRouter } from 'next/router';
export default function Product() {
const { query } = useRouter();
return <h1>Product ID: {query.id}</h1>;
}
/product/123
. <Image>
автоматически обрабатывает загрузку и размеры. import { render, screen } from "@testing-library/react";
import MyComponent from "./MyComponent";
test("renders the title", () => {
render(<MyComponent />);
const title = screen.getByText(/Hello, World!/i);
expect(title).toBeInTheDocument();
});
getBy*
, queryBy*
и findBy*
в зависимости от контекста: getBy*
— для синхронного поиска. findBy*
— для асинхронных тестов. queryBy*
— когда элемент может отсутствовать. import { render, screen } from "@testing-library/react";
import MyButton from "./MyButton";
test("renders button with the correct label", () => {
render(<MyButton label="Click me" />);
const button = screen.getByText(/Click me/i);
expect(button).toBeInTheDocument();
});
import { render, screen, fireEvent } from "@testing-library/react";
import Counter from "./Counter";
test("increments counter on button click", () => {
render(<Counter />);
const button = screen.getByRole("button", { name: /increment/i });
fireEvent.click(button);
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
fireEvent
для сложных взаимодействий используйте user-event — это ближе к реальным сценариям. import { render, screen } from "@testing-library/react";
import ConditionalComponent from "./ConditionalComponent";
test("renders message when condition is true", () => {
render(<ConditionalComponent show={true} />);
expect(screen.getByText(/Condition is true/i)).toBeInTheDocument();
});
test("does not render message when condition is false", () => {
render(<ConditionalComponent show={false} />);
expect(screen.queryByText(/Condition is true/i)).toBeNull();
});
import renderer from "react-test-renderer";
import MyComponent from "./MyComponent";
test("matches the snapshot", () => {
const tree = renderer.create(<MyComponent />).toJSON();
expect(tree).toMatchSnapshot();
});
useLayoutEffect
— это хук, который запускается синхронно после всех изменений в DOM. В отличие от useEffect
, он срабатывает до того, как браузер отрисует кадр, что делает его полезным для измерения и изменения DOM. import { useLayoutEffect, useRef } from "react";
function ScrollToBottom() {
const containerRef = useRef(null);
useLayoutEffect(() => {
const container = containerRef.current;
if (container) {
container.scrollTop = container.scrollHeight; // Прокрутка до конца
}
}, []); // Пустой массив зависимостей — эффект выполняется один раз
return (
<div
ref={containerRef}
style={{ height: "200px", overflow: "auto", border: "1px solid black" }}
>
<p>Содержимое...</p>
<p>Много текста...</p>
</div>
);
}
useLayoutEffect
гарантирует, что прокрутка произойдёт *до* отрисовки обновлённого интерфейса.useEffect
выполняется асинхронно, после отрисовки. useLayoutEffect
блокирует отрисовку, поэтому может замедлить загрузку страницы при избыточном использовании. useLayoutEffect
только тогда, когда useEffect
не подходит, например, если вы видите "мерцания" в интерфейсе из-за запоздалых измерений или изменений DOM. useEffect
. useLayoutEffect
.interface User {
id: number;
name: string;
email: string;
}
const user: User = {
id: 1,
name: 'John Doe',
email: 'john.doe@example.com',
};
User
: id
, name
и email
. interface User {
id: number;
name: string;
email?: string; // Опциональное свойство
}
const userWithoutEmail: User = {
id: 2,
name: 'Jane Smith',
};
extends
: interface User {
id: number;
name: string;
}
interface Admin extends User {
role: string;
}
const admin: Admin = {
id: 1,
name: 'Admin User',
role: 'Super Admin',
};
interface ButtonProps {
label: string;
onClick: () => void;
}
const Button: React.FC<ButtonProps> = ({ label, onClick }) => (
<button onClick={onClick}>{label}</button>
);
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
Counter
, который увеличивает счётчик на 1 при каждом нажатии кнопки.// Counter.js
import React, { useState } from 'react';
const Counter = () => {
const [count, setCount] = useState(0);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
export default Counter;
// Counter.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';
test('увеличение счётчика при нажатии кнопки', () => {
render(<Counter />); // Рендерим компонент
expect(screen.getByText(/Count:/).textContent).toBe('Count: 0');
const button = screen.getByText('Increment');
fireEvent.click(button);
expect(screen.getByText(/Count:/).textContent).toBe('Count: 1');
});
render
.screen.getByText
для поиска текста на экране.fireEvent
.screen.getByText
или screen.getByRole
).waitFor
или findBy
для правильной работы с промисами и API.const withExtraProps = (WrappedComponent) => {
return (props) => {
// Добавляем новые пропсы или логику
const extraProps = { additional: 'value' };
return <WrappedComponent {...props} {...extraProps} />;
};
};
import React from 'react';
// HOC для проверки авторизации
const withAuth = (WrappedComponent) => {
return (props) => {
const isAuthenticated = props.isAuthenticated; // Получаем пропс
if (!isAuthenticated) {
return <div>Доступ запрещён. Пожалуйста, войдите в систему.</div>;
}
return <WrappedComponent {...props} />;
};
};
// Пример компонента
const UserProfile = (props) => {
return <div>Привет, {props.name}! Это твой профиль.</div>;
};
// Оборачиваем компонент
const ProtectedUserProfile = withAuth(UserProfile);
// Использование
export default function App() {
return (
<div>
<ProtectedUserProfile isAuthenticated={false} name="Алексей" />
</div>
);
}
UserProfile
отобразится сообщение "Доступ запрещён". <WrappedComponent {...props} />
withAuth
, withLogger
. query {
user(id: "1") {
id
name
}
}
{
"data": {
"user": {
"id": "1",
"name": "Alice",
"email": "alice@example.com"
}
}
}
type User {
id: ID!
name: String!
email: String!
}
type Query {
user(id: ID!): User
}
const resolvers = {
Query: {
user: (_, { id }) => getUserById(id),
},
};
npm install @apollo/client graphql
import { useQuery, gql } from '@apollo/client';
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
}
}
`;
function User({ id }) {
const { loading, error, data } = useQuery(GET_USER, { variables: { id } });
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<h1>{data.user.name}</h1>
<p>{data.user.email}</p>
</div>
);
}
useRef
— это React-хук, который возвращает объект с единственным свойством current
. Этот объект сохраняется между рендерами, но изменение его значения не вызывает перерендер компонента. import React, { useRef } from 'react';
function InputFocus() {
const inputRef = useRef(null);
const handleFocus = () => {
inputRef.current?.focus(); // Устанавливаем фокус на input
};
return (
<div>
<input ref={inputRef} type="text" placeholder="Введите текст" />
<button onClick={handleFocus}>Фокус на поле ввода</button>
</div>
);
}
useRef
отлично подходит для хранения данных, которые не должны теряться между рендерами, но не требуют обновления UI. import React, { useRef, useState } from 'react';
function Counter() {
const renderCount = useRef(0); // Сохраняем количество рендеров
const [count, setCount] = useState(0);
renderCount.current += 1;
return (
<div>
<p>Счетчик: {count}</p>
<p>Количество рендеров: {renderCount.current}</p>
<button onClick={() => setCount(count + 1)}>Увеличить</button>
</div>
);
}
useRef
для сохранения данных, которые не должны пересоздаваться при каждом рендере, например, таймеры или идентификаторы. import React, { useRef, useEffect } from 'react';
function Timer() {
const timerId = useRef(null);
useEffect(() => {
timerId.current = setInterval(() => {
console.log('Таймер работает!');
}, 1000);
return () => {
if (timerId.current) clearInterval(timerId.current); // Очищаем таймер
};
}, []);
return <div>Таймер запущен. Проверьте консоль!</div>;
}
ref.current
не заставляет React обновлять компонент, это просто ссылка. useState
. useRef
— мощный инструмент, который помогает эффективно работать с DOM и сохранять значения между рендерами. Используйте его, когда вам нужно управлять DOM-узлами, хранить "незаметные" данные или улучшить производительность.===
). Если вы подписываетесь на сложный объект, это может приводить к ненужным ререндерам, так как объект создаётся заново при каждом изменении состояния. shallow
решает эту проблему: он выполняет поверхностное сравнение полей объектов или массивов. import { shallow } from 'zustand/shallow';
const { fieldA, fieldB } = useStore(
(state) => ({ fieldA: state.fieldA, fieldB: state.fieldB }),
shallow
);
shallow
? const items = useStore((state) => state.items, shallow);
shallow
в паре с селекторами — мощный инструмент для повышения производительности! localStorage
. import create from 'zustand';
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
);
import create from 'zustand';
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}),
{
name: 'counter-storage',
}
)
);
set
. const useStore = create((set) => ({
users: [],
fetchUsers: async () => {
const response = await fetch('https://api.example.com/users');
const data = await response.json();
set({ users: data });
},
}));
function UserList() {
const { users, fetchUsers } = useStore();
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
const useAuthStore = create((set) => ({
isAuthenticated: false,
login: () => set({ isAuthenticated: true }),
}));
const useUIStore = create((set) => ({
theme: 'light',
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}));
import styles from './Button.module.css';
function Button() {
return <button className={styles.primary}>Кликни меня</button>;
}
/* Button.module.css */
.primary {
background-color: blue;
color: white;
}
import styled from 'styled-components';
const Button = styled.button`
background-color: blue;
color: white;
&:hover {
background-color: darkblue;
}
`;
function App() {
return <Button>Кликни меня</Button>;
}
function Button() {
return (
<button className="bg-blue-500 text-white px-4 py-2 hover:bg-blue-700">
Кликни меня
</button>
);
}
<link>
или import
. import './styles.css';
function Button() {
return <button className="primary">Кликни меня</button>;
}
/* styles.css */
.primary {
background-color: blue;
color: white;
}