Using generalized components in React - reactjs

Say I wanted a generalized component for a dropdown like so:
import React, { useState } from "react";
export default function DropDown() {
const [menuOpen, setMenuOpen] = useState(false);
const clickHandler = () => {
setMenuOpen(true);
setTimeout(() => {
setMenuOpen(false);
}, 2000);
props.onClick();
}
return (
<p>Dropdown</p>
{menuOpen && <p>{props.text}</p>}
)
}
I want to then be able to use this dropdown for several components (e.g. a save button, switch between light and dark modes, etc.).
Here's a sample save button:
import React from "react";
import DropDown from "./DropDown";
export default function SaveButton() {
return (
<DropDown text="Save" onClick={() => console.log("Saved")}
)
}
For the light and dark mode button, I would want to console.log whether we're currently on light or dark mode.
import React from "react";
import DropDown from "./DropDown";
export default function LightDark() {
return (
<DropDown text="Save" onClick={() => console.log(menuOpen ? "light" : "dark")}
) // Don't have access to menuOpen
}
I know this is a simple example (the actual code I'm working on involves more complicated casework). But what's the best way to deal with a situation like this, where I want to combine functionality into a general component, but the individual components differ enough that it may be difficult? Here are some of my thoughts: to use casework in generalized component (Case 1), to handle it within the save button or light/dark mode component (Case 2)? Or perhaps another solution?
Case 1 Example:
// DropDown.js
const clickHandler = () => {
setMenuOpen(true);
setTimeout(() => {
setMenuOpen(false);
}, 2000);
if (props.text === "Save") {
// Blah
} else {
// Blah
} // Although this doesn't seem like a good solution for more complicated cases where this generalized component may be used a lot
}
Case 2 Example:
// LightDark.js
import React from "react";
import DropDown from "./DropDown";
export default function LightDark() {
return (
<DropDown text="Save" onClick={(menuOpen) => console.log(menuOpen ? "light" : "dark")} // menuOpen passed from higher order component, also doesn't feel like a good solution when things get complicated
)
}
It may be worth noting that I would like to use functional components.

I understood you want to create a UI component that provides some sort of context menu and can be reused for different actions (e.g. changing the theme, saving/discarding content). Provided my interpretation is correct, the example below might help you to achieve what you want.
Some additional remarks:
I suggest being careful with the term Dropdown in this context as what you're looking for IMO is closer to a context menu
To store the theme (or any value that should be globally available across your app), you'd want to use a React Context rather than a state.
Once you have a good overview over the use cases you'd want to use this component for, you should review the case for re-use and weigh its benefits against the complexity it forces you to introduce
const { useState, useEffect } = React;
const ThemeContext = React.createContext("dark");
const CustomMenu = (props) => {
const [menuOpen, setMenuOpen] = useState(false);
const handleLabelClick = () => {
setMenuOpen(true);
setTimeout(() => {
setMenuOpen(false);
}, 2000);
};
return (
<div>
<button onClick={handleLabelClick}> {props.label} </button>
{menuOpen &&
props.options.map((e) => <p onClick={e.action}> {e.label} </p>)}
</div>
);
};
const App = () => {
const [theme, setTheme] = useState("light");
useEffect(() => {
console.log(`Theme changed to ${theme}`);
}, [theme]);
const themeOptions = [
{
label: "light",
action: () => setTheme("light"),
},
{
label: "dark",
action: () => setTheme("dark"),
},
];
const saveOptions = [
{
label: "save",
action: () => console.log("saved"),
},
{
label: "discard",
action: () => console.log("discarded"),
},
];
return (
<div>
<CustomMenu label="Select Theme" options={themeOptions} />
<CustomMenu label="Action" options={saveOptions} />
</div>
);
};
ReactDOM.render(<App />, document.getElementById("root"));
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>

Related

Use context for communication between components at different level

I'm building the settings pages of my apps, in which we have a common SettingsLayout (parent component) which is rended for all the settings page. A particularity of this layout is that it contains an ActionsBar, in which the submit/save button for persisting the data lives.
However, the content of this SettingsLayout is different for each page, as every one of them has a different form and a different way to interact with it. For persisting the data to the backend, we use an Apollo Mutation, which is called in one of the child components, that's why there is no access to the ActionsBar save button.
For this implementation, I thought React Context was the most appropriated approach. At the beginning, I thought of using a Ref, which was updated with the submit handler function in each different render to be aware of the changes.
I've implemented a codesandbox with a very small and reduced app example to try to illustrate and clarify better what I try to implement.
https://codesandbox.io/s/romantic-tdd-y8tpj8?file=/src/App.tsx
Is there any caveat with this approach?
import React from "react";
import "./styles.css";
type State = {
onSubmit?: React.MutableRefObject<() => void>;
};
type SettingsContextProviderProps = {
children: React.ReactNode;
value?: State;
};
type ContextType = State;
const SettingsContext = React.createContext<ContextType | undefined>(undefined);
export const SettingsContextProvider: React.FC<SettingsContextProviderProps> = ({
children
}) => {
const onSubmit = React.useRef(() => {});
return (
<SettingsContext.Provider value={{ onSubmit }}>
{children}
</SettingsContext.Provider>
);
};
export const useSettingsContext = (): ContextType => {
const context = React.useContext(SettingsContext);
if (typeof context === "undefined") {
/*throw new Error(
"useSettingsContext must be used within a SettingsContextProvider"
);*/
return {};
}
return context;
};
function ExampleForm() {
const { onSubmit } = useSettingsContext();
const [input1, setInput1] = React.useState("");
const [input2, setInput2] = React.useState("");
onSubmit.current = () => {
console.log({ input1, input2 });
};
return (
<div className="exampleForm">
<input
placeholder="Input 1"
onChange={(event) => setInput1(event.target.value)}
/>
<input
placeholder="Input 2"
onChange={(event) => setInput2(event.target.value)}
/>
</div>
);
}
function ActionsBar() {
const { onSubmit } = useSettingsContext();
return (
<section className="actionsBar">
<strong>SETTINGS</strong>
<button onClick={() => onSubmit?.current()}>Save</button>
</section>
);
}
export default function App() {
return (
<div className="App">
<SettingsContextProvider>
<ActionsBar />
<ExampleForm />
</SettingsContextProvider>
</div>
);
}
The main caveat I see in this approach is that you change the whole submit function when you need only reaction to submit event. Event is the catch, I think.
Your approach works ok, but has no extension points, for cases such as validation etc.
So I propose to use EventEmitter in any form (better with types support) as a context value e.g. communication channel.
This is a fork of your codesandbox that illustrates this approach:
https://codesandbox.io/s/friendly-fog-qlrusj?file=/src/App.tsx

How to share a single MUI useScrollTrigger return value among multiple components?

I am currently using MUI's useScrollTrigger hook to determine the appearance of three components - NavBar, a post FAB a back to top button e.g.:
export default function NavBar() {
const isScrolledDown = useScrollTrigger({ target: window, threshold: 100 });
return (
<>
<Slide in={!isScrolledDown} >
<AppBar>
<Toolbar>
</Toolbar>
</AppBar>
</Slide>
<Toolbar />
<BackToTopFAB isScrolledDown={isScrolledDown} />
<PostCreateFAB isScrolledDown={isScrolledDown} />
</>
);
}
Since I do not want to make the browser listen for three separate "scroll" events, I am currently drilling the hook's return value from the NavBar into the two buttons.
However, as a result, I am unable to decouple the two buttons from the NavBar.
Does anyone have any suggestions how this may be possible, so that all three components share the same hook return value? If having multiple "scroll" listeners is not DOM-intensive, I am also willing to consider that
React hook is designed to be reusable, you probably want to move the useScrollTrigger hook to the components that need it like below:
const useCustomScrollTrigger = () => useScrollTrigger({ target: window, threshold: 100 });
const BackToTopFAB = () => {
const isScrolledDown = useCustomScrollTrigger();
return (...)
}
const PostCreateFAB = () => {
const isScrolledDown = useCustomScrollTrigger();
return (...)
}
const MyAppBar = () => {
const isScrolledDown = useCustomScrollTrigger();
return (
<Slide in={!isScrolledDown} >
<AppBar />
</Slide>
)
}
export default function NavBar() {
return (
<>
<MyAppBar />
<OtherContent />
<BackToTopFAB />
<PostCreateFAB />
</>
);
}
Doing so has a couple of advantages:
Your code is easier to read because the logic is hidden away in each specific component. Code readability is one of the most important factors when choosing between trade-offs IMO. Several additional event listeners should never impact your application performance in any way.
Improve your the performance of the parent component since there is no props at the top-level component, if the isScrolledDown state is changed, only 3 isolated components are re-rendered as a result. Otherwise, other components in the page like OtherContent also need to be rendered because the state in the parent component changes.
You can also have a look at some react state management libraries like redux-toolkit if you want to store the state in a single place and access it anywhere in the components regardless of its position in the hierarchy:
import { createSlice } from '#reduxjs/toolkit'
const { actions } = createSlice({
name: 'globalState',
initialState: { isScrolledDown: false },
reducers: {
setIsScrolledDown: (state, action) => {
state.isScrolledDown = action.payload
},
},
})
const ScrollLisenter = () => {
const isScrolledDown = useScrollTrigger({ /* ... */ });
const dispatch = useDispatch()
useEffect(() => {
dispatch(actions.setIsScrolledDown(isScrolledDown));
}, [isScrolledDown]);
return null
}
const BackToTopFAB = () => {
const isScrolledDown = useSelector(state => state.globalState.isScrolledDown);
return (...)
}
const PostCreateFAB = () => {
const isScrolledDown = useSelector(state => state.globalState.isScrolledDown);
return (...)
}
<App>
<ScrollLisenter />
<NavBar />
</App>
Related Question
Does adding too many event listeners affect performance?

Set React Context inside function-only component

My goal is very simple. I am just looking to set my react context from within a reusable function-only (stateless?) react component.
When this reusable function gets called it will set the context (state inside) to values i provide. The problem is of course you can't import react inside a function-only component and hence I cannot set the context throughout my app.
There's nothing really to show its a simple problem.
But just in case:
<button onCLick={() => PlaySong()}></button>
export function PlaySong() {
const {currentSong, setCurrentSong} = useContext(StoreContext) //cannot call useContext in this component
}
If i use a regular react component, i cannot call this function onClick:
export default function PlaySong() {
const {currentSong, setCurrentSong} = useContext(StoreContext) //fine
}
But:
<button onCLick={() => <PlaySong />}></button> //not an executable function
One solution: I know i can easily solve this problem by simply creating a Playbtn component and place that in every song so it plays the song. The problem with this approach is that i am using a react-player library so i cannot place a Playbtn component in there...
You're so close! You just need to define the callback inside the function component.
export const PlaySongButton = ({...props}) => {
const {setCurrentSong} = useContext(StoreContext);
const playSong = () => {
setCurrentSong("some song");
}
return (
<button
{...props}
onClick={() => playSong()}
/>
)
}
If you want greater re-usability, you can create custom hooks to consume your context. Of course where you use these still has to follow the rules of hooks.
export const useSetCurrentSong = (song) => {
const {setCurrentSong} = useContext(StoreContext);
setCurrentSong(song);
}
It is possible to trigger a hook function by rendering a component, but you cannot call a component like you are trying to do.
const PlaySong = () => {
const {setCurrentSong} = useContext(StoreContext);
useEffect( () => {
setCurrentSong("some song");
}, []
}
return null;
}
const MyComponent = () => {
const [shouldPlay, setShouldPlay] = useState(false);
return (
<>
<button onClick={() => setShouldPlay(true)}>Play</button>
{shouldPlay && <PlaySong />}
</>
)
}

Tooltip delay on hover with RXJS

I'm trying to add tooltip delay (300msemphasized text) using rxjs (without setTimeout()). My goal is to have this logic inside of TooltipPopover component which will be later be reused and delay will be passed (if needed) as a prop.
I'm not sure how can I add "delay" logic inside of TooltipPopover component using rxjs?
Portal.js
const Portal = ({ children }) => {
const mount = document.getElementById("portal-root");
const el = document.createElement("div");
useEffect(() => {
mount.appendChild(el);
return () => mount.removeChild(el);
}, [el, mount]);
return createPortal(children, el);
};
export default Portal;
TooltipPopover.js
import React from "react";
const TooltipPopover = ({ delay??? }) => {
return (
<div className="ant-popover-title">Title</div>
<div className="ant-popover-inner-content">{children}</div>
);
};
App.js
const App = () => {
return (
<Portal>
<TooltipPopover>
<div>
Content...
</div>
</TooltipPopover>
</Portal>
);
};
Then, I'm rendering TooltipPopover in different places:
ReactDOM.render(<TooltipPopover delay={1000}>
<SomeChildComponent/>
</TooltipPopover>, rootEl)
Here would be my approach:
mouseenter$.pipe(
// by default, the tooltip is not shown
startWith(CLOSE_TOOLTIP),
switchMap(
() => concat(timer(300), NEVER).pipe(
mapTo(SHOW_TOOLTIP),
takeUntil(mouseleave$),
endWith(CLOSE_TOOLTIP),
),
),
distinctUntilChanged(),
)
I'm not very familiar with best practices in React with RxJS, but this would be my reasoning. So, the flow would be this:
on mouseenter$, start the timer. concat(timer(300), NEVER) is used because although after 300ms the tooltip should be shown, we only want to hide it when mouseleave$ emits.
after 300ms, the tooltip is shown and will be closed mouseleave$
if mouseleave$ emits before 300ms pass, the CLOSE_TOOLTIP will emit, but you could avoid(I think) unnecessary re-renders with the help of distinctUntilChanged

React Hook does not work properly on the first render in gatsby production mode

I have the following Problem:
I have a gatsby website that uses emotion for css in js. I use emotion theming to implement a dark mode. The dark mode works as expected when I run gatsby develop, but does not work if I run it with gatsby build && gatsby serve. More specifically the dark mode works only after switching to light and back again.
I have to following top level component which handles the Theme:
const Layout = ({ children }) => {
const [isDark, setIsDark] = useState(() => getInitialIsDark())
useEffect(() => {
if (typeof window !== "undefined") {
console.log("save is dark " + isDark)
window.localStorage.setItem("theming:isDark", isDark.toString())
}
}, [isDark])
return (
<ThemeProvider theme={isDark ? themeDark : themeLight}>
<ThemedLayout setIsDark={() => setIsDark(!isDark)} isDark={isDark}>{children}</ThemedLayout>
</ThemeProvider>
)
}
The getInitalIsDark function checks a localStorage value, the OS color scheme, and defaults to false. If I run the application, and activate the dark mode the localStorage value is set. If i do now reload the Application the getInitialIsDark method returns true, but the UI Renders the light Theme. Switching back and forth between light and dark works as expected, just the initial load does not work.
If I replace the getInitialIsDark with true loading the darkMode works as expected, but the lightMode is broken. The only way I got this to work is to automatically rerender after loading on time using the following code.
const Layout = ({ children }) => {
const [isDark, setIsDark] = useState(false)
const [isReady, setIsReady] = useState(false)
useEffect(() => {
if (typeof window !== "undefined" && isReady) {
console.log("save is dark " + isDark)
window.localStorage.setItem("theming:isDark", isDark.toString())
}
}, [isDark, isReady])
useEffect(() => setIsReady(true), [])
useEffect(() => {
const useDark = getInitialIsDark()
console.log("init is dark " + useDark)
setIsDark(useDark)
}, [])
return (
<ThemeProvider theme={isDark ? themeDark : themeLight}>
{isReady ? (<ThemedLayout setIsDark={() => setIsDark(!isDark)} isDark={isDark}>{children}</ThemedLayout>) : <div/>}
</ThemeProvider>
)
}
But this causes an ugly flicker on page load.
What am I doing wrong with the hook in the first approach, that the initial value is not working as I expect.
Did you try to set your initial state like this?
const [isDark, setIsDark] = useState(getInitialIsDark())
Notice that I am not wrapping getInitialIsDark() in an additional function:
useState(() => getInitialIsDark())
You will probably crash your build because localStorage is not defined at buildtime. You might need to check if that exists inside getInitialIsDark.
Hope this helps!
#PedroFilipe is correct, useState(() => getInitialIsDark()) is not the way to invoke the checking function on start-up. The expression () => getInitialIsDark() is truthy, so depending on how <ThemedLayout isDark={isDark}> uses the prop it might work by accident, but useState will not evaluate the fuction passed in (as far as I know).
When using an initial value const [myValue, setMyValue] = useState(someInitialValue) the value seen in myValue can be laggy. I'm not sure why, but it seems to be a common cause of problems with hooks.
If the component always renders multiple times (e.g something else is async) the problem does not appear because in the second render the variable will have the expected value.
To be sure you check localstorage on startup, you need an additional useEffect() which explicitly calls your function.
useEffect(() => {
setIsDark(getInitialIsDark());
}, [getInitialIsDark]); //dependency only needed to satisfy linter, essentially runs on mount.
Although most useEffect examples use an anonymous function, you might find more understandable to use named functions (following the clean-code principle of using function names for documentation)
useEffect(function checkOnMount() {
setIsDark(getInitialIsDark());
}, [getInitialIsDark]);
useEffect(function persistOnChange() {
if (typeof window !== "undefined" && isReady) {
console.log("save is dark " + isDark)
window.localStorage.setItem("theming:isDark", isDark.toString())
}
}, [isDark])
I had a similar issue where some styles weren't taking effect because they were being applied to through classes which were set on mount (like you only on production build, everything worked fine in develop).
I ended up switching the hydrate function React was using from ReactDOM.hydrate to ReactDOM.render and the issue disappeared.
// gatsby-browser.js
export const replaceHydrateFunction = () => (element, container, callback) => {
ReactDOM.render(element, container, callback);
};
This is what worked for me, try this and let me know if it works out.
First
In src/components/ i've created a component navigation.js
export default class Navigation extends Component {
static contextType = ThemeContext // eslint-disable-line
render() {
const theme = this.context
return (
<nav className={'nav scroll' : 'nav'}>
<div className="nav-container">
<button
className="dark-switcher"
onClick={theme.toggleDark}
title="Toggle Dark Mode"
>
</button>
</div>
</nav>
)
}
}
Second
Created a gatsby-browser.js
import React from 'react'
import { ThemeProvider } from './src/context/ThemeContext'
export const wrapRootElement = ({ element }) => <ThemeProvider>{element}</ThemeProvider>
Third
I've created a ThemeContext.js file in src/context/
import React, { Component } from 'react'
const defaultState = {
dark: false,
notFound: false,
toggleDark: () => {},
}
const ThemeContext = React.createContext(defaultState)
class ThemeProvider extends Component {
state = {
dark: false,
notFound: false,
}
componentDidMount() {
const lsDark = JSON.parse(localStorage.getItem('dark'))
if (lsDark) {
this.setState({ dark: lsDark })
}
}
componentDidUpdate(prevState) {
const { dark } = this.state
if (prevState.dark !== dark) {
localStorage.setItem('dark', JSON.stringify(dark))
}
}
toggleDark = () => {
this.setState(prevState => ({ dark: !prevState.dark }))
}
setNotFound = () => {
this.setState({ notFound: true })
}
setFound = () => {
this.setState({ notFound: false })
}
render() {
const { children } = this.props
const { dark, notFound } = this.state
return (
<ThemeContext.Provider
value={{
dark,
notFound,
setFound: this.setFound,
setNotFound: this.setNotFound,
toggleDark: this.toggleDark,
}}
>
{children}
</ThemeContext.Provider>
)
}
}
export default ThemeContext
export { ThemeProvider }
This should work for you here is the reference I followed from the official Gatsby site

Resources