diff --git a/tgui/docs/state-usage.md b/tgui/docs/state-usage.md new file mode 100644 index 00000000000..9d3a2812a68 --- /dev/null +++ b/tgui/docs/state-usage.md @@ -0,0 +1,30 @@ +# Managing component state + +React has excellent documentation on useState and useEffect. These hooks should be the ways to manage state in TGUI (v5). +[React Hooks](https://react.dev/learn/state-a-components-memory) + +You might find usages of useLocalState. This should be considered deprecated and will be removed in the future. In older versions of TGUI, InfernoJS did not have hooks, so these were used to manage state. useSharedState is still used in some places where uis are considered "IC" and user input is shared with all persons at the console/machine/thing. + +## A Note on State + +Many beginners tend to overuse state (or hooks all together). State is effective when you want to implement user interactivity, or are handling asynchronous data, but if you are simply using state to store a value that is not changing, you should consider using a variable instead. + +In previous versions of React, each setState would trigger a re-render, which would cause poorly written components to cascade re-render on each page load. Messy! Though this is no longer the case with batch rendering, it's still worthwhile to point out that you might be overusing it. + +## Derived state + +One great way to cut back on state usage is by using props or other state as the basis for a variable. You'll see many examples of this in the TGUI codebase. What does this mean? Here's an example: + +```tsx +// Bad +const [count, setCount] = useState(0); +const [isEven, setIsEven] = useState(false); + +useEffect(() => { + setIsEven(count % 2 === 0); +}, [count]); + +// Good! +const [count, setCount] = useState(0); +const isEven = count % 2 === 0; // Derived state +``` diff --git a/tgui/packages/tgui/backend.ts b/tgui/packages/tgui/backend.ts index 464428fdcfd..9f865994574 100644 --- a/tgui/packages/tgui/backend.ts +++ b/tgui/packages/tgui/backend.ts @@ -316,6 +316,7 @@ type StateWithSetter = [T, (nextState: T) => void]; * @param context React context. * @param key Key which uniquely identifies this state in Redux store. * @param initialState Initializes your global variable with this value. + * @deprecated Use useState and useEffect when you can. Pass the state as a prop. */ export const useLocalState = ( key: string, diff --git a/tgui/packages/tgui/interfaces/AbductorConsole.jsx b/tgui/packages/tgui/interfaces/AbductorConsole.jsx index 4ba7b4488eb..e870089a1bc 100644 --- a/tgui/packages/tgui/interfaces/AbductorConsole.jsx +++ b/tgui/packages/tgui/interfaces/AbductorConsole.jsx @@ -5,6 +5,7 @@ import { GenericUplink } from './Uplink/GenericUplink'; export const AbductorConsole = (props) => { const [tab, setTab] = useSharedState('tab', 1); + return ( diff --git a/tgui/packages/tgui/interfaces/AccountingConsole.tsx b/tgui/packages/tgui/interfaces/AccountingConsole.tsx index 4aa4633e10a..a3f23627753 100644 --- a/tgui/packages/tgui/interfaces/AccountingConsole.tsx +++ b/tgui/packages/tgui/interfaces/AccountingConsole.tsx @@ -7,9 +7,9 @@ import { Tabs, } from '../components'; import { useBackend } from '../backend'; -import { useLocalState } from '../backend'; import { Window } from '../layouts'; import { BooleanLike } from 'common/react'; +import { useState } from 'react'; type Data = { PlayerAccounts: PlayerAccount[]; @@ -38,7 +38,7 @@ enum SCREENS { } export const AccountingConsole = (props) => { - const [screenmode, setScreenmode] = useLocalState('tab_main', SCREENS.users); + const [screenmode, setScreenmode] = useState(SCREENS.users); return ( diff --git a/tgui/packages/tgui/interfaces/Achievements.jsx b/tgui/packages/tgui/interfaces/Achievements.jsx index 9bd52fb4f8c..bef15df4bc0 100644 --- a/tgui/packages/tgui/interfaces/Achievements.jsx +++ b/tgui/packages/tgui/interfaces/Achievements.jsx @@ -1,6 +1,7 @@ import { useBackend, useLocalState } from '../backend'; import { Box, Flex, Icon, Table, Tabs, Tooltip } from '../components'; import { Window } from '../layouts'; +import { useState } from 'react'; export const Achievements = (props) => { const { data } = useBackend(); @@ -94,7 +95,7 @@ const Achievement = (props) => { const HighScoreTable = (props) => { const { data } = useBackend(); const { highscore: highscores, user_ckey } = data; - const [highScoreIndex, setHighScoreIndex] = useLocalState('highscore', 0); + const [highScoreIndex, setHighScoreIndex] = useState(0); const highscore = highscores[highScoreIndex]; if (!highscore) { return null; diff --git a/tgui/packages/tgui/interfaces/Adminhelp.tsx b/tgui/packages/tgui/interfaces/Adminhelp.tsx index 51ba58ffd75..c99a2764715 100644 --- a/tgui/packages/tgui/interfaces/Adminhelp.tsx +++ b/tgui/packages/tgui/interfaces/Adminhelp.tsx @@ -1,5 +1,6 @@ import { BooleanLike } from 'common/react'; -import { useBackend, useLocalState } from '../backend'; +import { useState } from 'react'; +import { useBackend } from '../backend'; import { TextArea, Stack, Button, NoticeBox, Input, Box } from '../components'; import { Window } from '../layouts'; @@ -18,15 +19,9 @@ export const Adminhelp = (props) => { bannedFromUrgentAhelp, urgentAhelpPromptMessage, } = data; - const [requestForAdmin, setRequestForAdmin] = useLocalState( - 'request_for_admin', - false, - ); - const [currentlyInputting, setCurrentlyInputting] = useLocalState( - 'confirm_request', - false, - ); - const [ahelpMessage, setAhelpMessage] = useLocalState('ahelp_message', ''); + const [requestForAdmin, setRequestForAdmin] = useState(false); + const [currentlyInputting, setCurrentlyInputting] = useState(false); + const [ahelpMessage, setAhelpMessage] = useState(''); const confirmationText = 'alert admins'; return ( diff --git a/tgui/packages/tgui/interfaces/AmmoWorkbench.jsx b/tgui/packages/tgui/interfaces/AmmoWorkbench.jsx index 5d757d4dbcb..01193775c4a 100644 --- a/tgui/packages/tgui/interfaces/AmmoWorkbench.jsx +++ b/tgui/packages/tgui/interfaces/AmmoWorkbench.jsx @@ -1,6 +1,6 @@ // THIS IS A SKYRAT UI FILE import { toTitleCase } from 'common/string'; -import { useBackend, useSharedState, useLocalState } from '../backend'; +import { useBackend, useSharedState } from '../backend'; import { Box, Button, @@ -16,6 +16,7 @@ import { Tooltip, } from '../components'; import { Window } from '../layouts'; +import { useState } from 'react'; export const AmmoWorkbench = (props) => { const [tab, setTab] = useSharedState('tab', 1); @@ -245,7 +246,7 @@ export const DatadiskTab = (props) => { const MaterialRow = (props) => { const { material, onRelease } = props; - const [amount, setAmount] = useLocalState('amount' + material.name, 1); + const [amount, setAmount] = useState(1); const amountAvailable = Math.floor(material.amount); return ( diff --git a/tgui/packages/tgui/interfaces/AnomalyRefinery.jsx b/tgui/packages/tgui/interfaces/AnomalyRefinery.jsx index 577cbbe1838..7f3f2c2be65 100644 --- a/tgui/packages/tgui/interfaces/AnomalyRefinery.jsx +++ b/tgui/packages/tgui/interfaces/AnomalyRefinery.jsx @@ -12,7 +12,6 @@ import { Window } from '../layouts'; import { GasmixParser } from './common/GasmixParser'; export const AnomalyRefinery = (props) => { - const { act, data } = useBackend(); return ( @@ -26,6 +25,7 @@ const AnomalyRefineryContent = (props) => { const { act, data } = useBackend(); const [currentTab, changeTab] = useSharedState('exploderTab', 1); const { core, valvePresent, active } = data; + return ( {currentTab === 1 && } diff --git a/tgui/packages/tgui/interfaces/AntagInfoAssaultops.tsx b/tgui/packages/tgui/interfaces/AntagInfoAssaultops.tsx index 51b95075f00..0966ec9ce7e 100644 --- a/tgui/packages/tgui/interfaces/AntagInfoAssaultops.tsx +++ b/tgui/packages/tgui/interfaces/AntagInfoAssaultops.tsx @@ -1,5 +1,5 @@ // THIS IS A SKYRAT UI FILE -import { useBackend, useLocalState } from '../backend'; +import { useBackend } from '../backend'; import { LabeledList, Stack, @@ -13,6 +13,7 @@ import { import { BooleanLike } from 'common/react'; import { Window } from '../layouts'; import { Rules } from './AntagInfoRules'; +import { useState } from 'react'; type Objectives = { count: number; @@ -51,7 +52,7 @@ type Info = { }; export const AntagInfoAssaultops = (props) => { - const [tab, setTab] = useLocalState('tab', 1); + const [tab, setTab] = useState(1); const { data } = useBackend(); const { required_keys, uploaded_keys, objectives } = data; return ( diff --git a/tgui/packages/tgui/interfaces/AntagInfoChangeling.tsx b/tgui/packages/tgui/interfaces/AntagInfoChangeling.tsx index 743d073d1e9..ec6f14e7b26 100644 --- a/tgui/packages/tgui/interfaces/AntagInfoChangeling.tsx +++ b/tgui/packages/tgui/interfaces/AntagInfoChangeling.tsx @@ -1,6 +1,7 @@ import { BooleanLike } from 'common/react'; import { multiline } from 'common/string'; -import { useBackend, useSharedState } from '../backend'; +import { useState } from 'react'; +import { useBackend } from '../backend'; import { Button, Dimmer, @@ -220,8 +221,7 @@ const AbilitiesSection = (props) => { const MemoriesSection = (props) => { const { data } = useBackend(); const { memories } = data; - const [selectedMemory, setSelectedMemory] = useSharedState( - 'memory', + const [selectedMemory, setSelectedMemory] = useState( (!!memories && memories[0]) || null, ); const memoryMap = {}; @@ -229,6 +229,7 @@ const MemoriesSection = (props) => { const memory = memories[index]; memoryMap[memory.name] = memory; } + return (
{ const { data } = useBackend(); const { ascended } = data; - const [currentTab, setTab] = useLocalState('currentTab', 0); + const [currentTab, setTab] = useState(0); return ( diff --git a/tgui/packages/tgui/interfaces/AntagInfoMalf.tsx b/tgui/packages/tgui/interfaces/AntagInfoMalf.tsx index fda1ec42388..b81143c1263 100644 --- a/tgui/packages/tgui/interfaces/AntagInfoMalf.tsx +++ b/tgui/packages/tgui/interfaces/AntagInfoMalf.tsx @@ -1,4 +1,4 @@ -import { useBackend, useLocalState } from '../backend'; +import { useBackend } from '../backend'; import { multiline } from 'common/string'; import { GenericUplink, Item } from './Uplink/GenericUplink'; import { BlockQuote, Button, Section, Stack, Tabs } from '../components'; @@ -9,6 +9,7 @@ import { Objective, ReplaceObjectivesButton, } from './common/Objectives'; +import { useState } from 'react'; // SKYRAT EDIT BEGIN import { Rules } from './AntagInfoRules'; // SKYRAT EDIT END @@ -178,7 +179,7 @@ const CodewordsSection = (props) => { export const AntagInfoMalf = (props) => { const { act, data } = useBackend(); const { processingTime, categories } = data; - const [antagInfoTab, setAntagInfoTab] = useLocalState('antagInfoTab', 0); + const [antagInfoTab, setAntagInfoTab] = useState(0); const categoriesList: string[] = []; const items: Item[] = []; for (let i = 0; i < categories.length; i++) { diff --git a/tgui/packages/tgui/interfaces/AtmosControlConsole.tsx b/tgui/packages/tgui/interfaces/AtmosControlConsole.tsx index 571da942106..997d296035f 100644 --- a/tgui/packages/tgui/interfaces/AtmosControlConsole.tsx +++ b/tgui/packages/tgui/interfaces/AtmosControlConsole.tsx @@ -1,4 +1,5 @@ -import { useBackend, useLocalState } from '../backend'; +import { useState } from 'react'; +import { useBackend } from '../backend'; import { Box, Button, @@ -32,7 +33,7 @@ export const AtmosControlConsole = (props) => { control: boolean; }>(); const chambers = data.chambers || []; - const [chamberId, setChamberId] = useLocalState('chamberId', chambers[0]?.id); + const [chamberId, setChamberId] = useState(chambers[0]?.id); const selectedChamber = chambers.length === 1 ? chambers[0] diff --git a/tgui/packages/tgui/interfaces/AuxBaseConsole.tsx b/tgui/packages/tgui/interfaces/AuxBaseConsole.tsx index a011e23c553..aec8e573440 100644 --- a/tgui/packages/tgui/interfaces/AuxBaseConsole.tsx +++ b/tgui/packages/tgui/interfaces/AuxBaseConsole.tsx @@ -1,5 +1,6 @@ import { BooleanLike } from 'common/react'; -import { useBackend, useLocalState } from '../backend'; +import { useState } from 'react'; +import { useBackend } from '../backend'; import { Button, NoticeBox, Section, Table, Tabs } from '../components'; import { Window } from '../layouts'; import { ShuttleConsoleContent } from './ShuttleConsole'; @@ -27,9 +28,14 @@ const STATUS_COLOR_KEYS = { 'All Clear': 'good', } as const; +enum TAB { + Shuttle = 1, + Aux, +} + export const AuxBaseConsole = (props) => { const { data } = useBackend(); - const [tab, setTab] = useLocalState('tab', 1); + const [tab, setTab] = useState(TAB.Shuttle); const { type, blind_drop, turrets = [] } = data; return ( @@ -56,10 +62,10 @@ export const AuxBaseConsole = (props) => { Turrets ({turrets.length}) - {tab === 1 && ( + {tab === TAB.Shuttle && ( )} - {tab === 2 && } + {tab === TAB.Aux && } ); diff --git a/tgui/packages/tgui/interfaces/BluespaceLocator.tsx b/tgui/packages/tgui/interfaces/BluespaceLocator.tsx index b77dae378f4..ee3c00ba9a6 100644 --- a/tgui/packages/tgui/interfaces/BluespaceLocator.tsx +++ b/tgui/packages/tgui/interfaces/BluespaceLocator.tsx @@ -1,4 +1,5 @@ -import { useBackend, useLocalState } from '../backend'; +import { useState } from 'react'; +import { useBackend } from '../backend'; import { Icon, ProgressBar, Tabs } from '../components'; import { Window } from '../layouts'; @@ -25,28 +26,33 @@ const DIRECTION_TO_ICON = { northwest: 315, } as const; +enum TAB { + Implant, + Beacon, +} + export const BluespaceLocator = (props) => { - const [tab, setTab] = useLocalState('tab', 'implant'); + const [tab, setTab] = useState(TAB.Implant); return ( setTab('implant')} + selected={tab === TAB.Implant} + onClick={() => setTab(TAB.Implant)} > Implants setTab('beacon')} + selected={tab === TAB.Beacon} + onClick={() => setTab(TAB.Beacon)} > Teleporter Beacons - {(tab === 'beacon' && ) || - (tab === 'implant' && )} + {(TAB.Beacon && ) || + (TAB.Implant && )} ); diff --git a/tgui/packages/tgui/interfaces/CameraConsole.tsx b/tgui/packages/tgui/interfaces/CameraConsole.tsx index d283d7e8b71..c0715d50578 100644 --- a/tgui/packages/tgui/interfaces/CameraConsole.tsx +++ b/tgui/packages/tgui/interfaces/CameraConsole.tsx @@ -2,7 +2,8 @@ import { filter, sortBy } from 'common/collections'; import { flow } from 'common/fp'; import { BooleanLike, classes } from 'common/react'; import { createSearch } from 'common/string'; -import { useBackend, useLocalState } from '../backend'; +import { useState } from 'react'; +import { useBackend } from '../backend'; import { Button, ByondUi, @@ -88,13 +89,15 @@ export const CameraConsole = (props) => { }; export const CameraContent = (props) => { + const [searchText, setSearchText] = useState(''); + return ( - + - + ); @@ -102,7 +105,7 @@ export const CameraContent = (props) => { const CameraSelector = (props) => { const { act, data } = useBackend(); - const [searchText, setSearchText] = useLocalState('searchText', ''); + const { searchText, setSearchText } = props; const { activeCamera } = data; const cameras = selectCameras(data.cameras, searchText); @@ -149,10 +152,10 @@ const CameraSelector = (props) => { ); }; -const CameraControls = (props) => { +const CameraControls = (props: { searchText: string }) => { const { act, data } = useBackend(); const { activeCamera, can_spy, mapRef } = data; - const [searchText] = useLocalState('searchText', ''); + const { searchText } = props; const cameras = selectCameras(data.cameras, searchText); diff --git a/tgui/packages/tgui/interfaces/Cargo.jsx b/tgui/packages/tgui/interfaces/Cargo.jsx index a41c605507a..01ef2f6f477 100644 --- a/tgui/packages/tgui/interfaces/Cargo.jsx +++ b/tgui/packages/tgui/interfaces/Cargo.jsx @@ -36,6 +36,7 @@ export const CargoContent = (props) => { const [tab, setTab] = useSharedState('tab', 'catalog'); const { cart = [], requests = [], requestonly } = data; const cart_length = cart.reduce((total, entry) => total + entry.amount, 0); + return ( @@ -108,6 +109,7 @@ const CargoStatus = (props) => { requestonly, can_send, } = data; + return (
{ - const [category, setCategory] = useLocalState('category', ''); - const [weapon, setArmament] = useLocalState('weapon'); + const [category, setCategory] = useState(''); + const [weapon, setArmament] = useState('weapon'); const { act, data } = useBackend(); const { armaments_list = [], diff --git a/tgui/packages/tgui/interfaces/CellularEmporium.tsx b/tgui/packages/tgui/interfaces/CellularEmporium.tsx index b1de3253d29..8f273ae08ac 100644 --- a/tgui/packages/tgui/interfaces/CellularEmporium.tsx +++ b/tgui/packages/tgui/interfaces/CellularEmporium.tsx @@ -1,5 +1,6 @@ +import { useState } from 'react'; import { BooleanLike } from '../../common/react'; -import { useBackend, useLocalState } from '../backend'; +import { useBackend } from '../backend'; import { Button, Section, @@ -35,10 +36,7 @@ type Ability = { export const CellularEmporium = (props) => { const { act, data } = useBackend(); - const [searchAbilities, setSearchAbilities] = useLocalState( - 'searchAbilities', - '', - ); + const [searchAbilities, setSearchAbilities] = useState(''); const { can_readapt, genetic_points_count } = data; return ( @@ -80,16 +78,16 @@ export const CellularEmporium = (props) => { } > - +
); }; -const AbilityList = (props) => { +const AbilityList = (props: { searchAbilities: string }) => { const { act, data } = useBackend(); - const [searchAbilities] = useLocalState('searchAbilities', ''); + const { searchAbilities } = props; const { abilities, owned_abilities, @@ -124,51 +122,49 @@ const AbilityList = (props) => { : 'No abilities found.'} ); - } else { - return ( - - {filteredAbilities.map((ability) => ( - - {ability.genetic_point_required} - - - - -