Always Pushing for Cleaner Code
While building my newest SaaS product, ReduxPlate, I realized a common pattern kept cropping up in my array manipulation functions. I was always updating a specific value at a specific key, based on a specific test on some other key.
*Plug: Speaking of ReduxPlate, which automatically generates Redux code for you, I'm writing a book that documents every step I took along the way to build ReduxPlate, from boilerplate starters to the finished live product. I'd love it if you check it out! Yes, You've read this correctly! I literally build ReduxPlate from start to finish, right before your eyes - and the code is all public!
For example, for the editor widget on the ReduxPlate homepage, I use a stateful array of type IEditorSettings to determine which editor is currently active and what the actual code value is in the editor:
export default interface IEditorSetting {
fileLabel: string
code: string
isActive: boolean
}
Such behavior required me to write two event handlers:
onChangeCode
for when the code changes:
const onChangeCode = (code: string) => {
setEditorSettingsState(editorSettingsState.map(editorSetting => {
if (editorSetting.isActive) {
editorSetting.code = code
}
return editorSetting
}))
}
and onChangeTab
for when the editor tab changes:
const onChangeTab = (fileLabel: string) => {
setEditorSettingsState(editorSettingsState.map(editorSetting => {
editorSetting.isActive = editorSetting.fileLabel === fileLabel
return editorSetting
}))
}
Examine these two functions closely. With both, I am mapping over a state variable editorSettingsState
and setting a property in the array according to some test condition. In the onChangeCode
, the test condition is if the isActive
property value is true. In onChangeTab
, the test condition is if fileLabel
property value matches the fileLabel
passed in. As opposed to onChangeCode
, onChangeTab
will set the isActive
value for all items in the array.
With a bit of effort, we should be able to implement a generic function that we can use to replace these functions, and more importantly: reuse throughout our applications anywhere we need the same type of functionality.
Rewriting Both Functions for a Better Overview of Their Structure
To get a better idea of the function we will write, let's expand the two functions with an else
statement, while keeping their functionalities exactly the same.
For onChangeCode
:
const onChangeCode = (code: string) => {
setEditorSettingsState(editorSettingsState.map(editorSetting => {
if (editorSetting.isActive) {
editorSetting.code = code
} else {
// do nothing :)
}
return editorSetting
}))
}
and for onChangeTab
:
const onChangeTab = (fileLabel: string) => {
setEditorSettingsState(editorSettingsState.map(editorSetting => {
if (editorSetting.fileLabel === fileLabel) {
editorSetting.isActive = true
} else {
editorSetting.isActive = false
}
return editorSetting
}))
}
In this form, it's clear that our generic function should have some sort of test criteria, which will live in the if
statement. Then we need the key and value of the property which is to be updated in the array if the test criteria passes. Furthermore, what occurs in the else
block should be optional - that is, there should be an optional way to set a default value if the test fails. Really what this means is that this will become an else if
block.
The body of our new generic function would then take on the same type of form as these two expanded functions:
return array.map(item => {
if (item[testKey] === testValue) {
item[updateKey] = updateValue
} else if (testFailValue !== undefined) {
item[updateKey] = testFailValue
}
return item
})
We'll need to provide a testKey
and value as our test criteria, as well as an updateKey
and updateValue
if the test passes. Finally, an optional parameter will be testFailValue
. If testFailValue
is not undefined
, then we will execute the else if
block.
Typing the Function
The most challenging part of writing this function was ensuring that the value passed for testValue
matches the expected type of T[testKey]
. The same should be true for updateValue
/ testFailValue
with T[updateKey]
. With TypeScript, it is possible to do this, though we'll need to explicitly provide a bit of information in the calling signature in order to enforce it. Our array
in question is of type Array<T>
, that much is clear. But what about the types for testKey
and updateKey
? We'll need to introduce two more generic types to get those to work, U
and V
. To ensure that both testKey
and updateKey
are actual keys of object T
, we'll employ TypeScripts's extends
keyword, i.e. defining U
as U extends keyof T
, and V
as V extends keyof T
.
With types U
and V
defined, testKey
and updateKey
can be defined by keyof T
, as well as their corresponding values: testValue
as T[U]
, and updateValue
as T[V]
. testFailValue
follows updateValue
with the identical type T[V]
. Finally, since this is an array function map
, we'll be returning a fresh array of type T
. Because this signature is rather complex, I add them all to a param
object so that when we call this updateArray
function, it will be easy to read and understand. Such a structure also makes it easier to extend and add additional parameters later.
So, we have our function signature:
export const updateArray = <T, U extends keyof T, V extends keyof T>(params: {
array: Array<T>
testKey: keyof T
testValue: T[U]
updateKey: keyof T
updateValue: T[V]
testFailValue?: T[V]
}): Array<T>
Final Result
Hooking in the map
logic from above, the full updateArray
function in full is:
// Updates an object array at the specified update key with the update value,
// if the specified test key matches the test value.
// Optionally pass 'testFailValue' to set a default value if the test fails.
export const updateArray = <T, U extends keyof T, V extends keyof T>(params: {
array: Array<T>
testKey: keyof T
testValue: T[U]
updateKey: keyof T
updateValue: T[V]
testFailValue?: T[V]
}): Array<T> => {
const {
array,
testKey,
testValue,
updateKey,
updateValue,
testFailValue,
} = params
return array.map(item => {
if (item[testKey] === testValue) {
item[updateKey] = updateValue
} else if (testFailValue !== undefined) {
item[updateKey] = testFailValue
}
return item
})
}
A possible improvement to add to this function might be to differentiate between the updateKey
on success and on fail. Perhaps in some rare case you would want to set the value of some other key if the test fails.
Use It!
Let's return to our original functions and refactor them to use our fancy generic function updateArray
.
Referring to IEditorSetting
above may be helpful (recall that editorSettingsState
is an array of IEditorSetting
). Here's the refactored onChangeCode
:
const onChangeCode = (code: string) => {
setEditorSettingsState(updateArray({
array: editorSettingsState,
testKey: "isActive",
testValue: true,
updateKey: "code",
updateValue: code,
}))
}
and onChangeTab
:
const onChangeTab = (fileLabel: string) => {
setEditorSettingsState(updateArray({
array: editorSettingsState,
testKey: "fileLabel",
testValue: fileLabel,
updateKey: "isActive",
updateValue: true,
testFailValue: false,
}))
}
Thanks to our U extends keyof T
and U extends keyof T
, our function is type safe: for example, TypeScript won't allow passing a string like "hello world"
to updateValue
, since the expected type for the IEditorSetting
on the isActive
key is boolean
.
Congratulations, we're done!
You may also want to check this snippet out on my Full Stack Snippets page, which has further additional snippet goods like this function!
Verbosity vs. Reusability and Readability
Indeed, calling updateArray
is rather verbose. However, this is a small price to pay when you consider that we no longer have to think about crafting all those pesky map
manipulations throughout our apps!
Is this an over-optimization? I don't think so - take a look at your own projects using either React or Redux, or both. I guarantee you have the same times of array mapping and manipulations, either in your state changes or render functions!
Thanks!
With this powerful generic function, you should never need to think about map
array manipulations at a property level ever again! Additionally, the strongly typed signature also protects you from passing either a testValue
or updateValue
that doesn't correspond with its respective key's expected type!
Cheers! 🍺
-Chris
Top comments (5)
Hi, I'm a Redux maintainer. Unfortunately, I have concerns with several of the things you've shown here in this post, and with the Redux Plate app you linked - the code patterns shown are the opposite of how we recommend people use Redux today.
The first thing I note is that the generated code from ReduxPlate is using very old patterns for action types and file structure It has "handwritten/manual" logic for action creators and reducers, and each of the different code types is being output in a separate file.
Instead, we recommend using our official Redux Toolkit package, which is the standard approach for writing Redux logic. RTK simplifies existing Redux code patterns, and is specifically designed for a good TS usage experience. You can see our recommended RTK+TS usage patterns here. We also recommend using a "feature folder" structure, with single-file "slices" for logic.
Using RTK and its
createSlice
utility eliminates the need to have hand-written action creators, or split that logic across multiple files, and the action types become a background implementation detail that you don't even have to think about.Next, the generated code is defining actions as a bunch of "setter functions", like
SET_FIRST_NAME
. We specifically recommend that you should model actions as "events", not "setters". Modeling actions as events leads to fewer actions being dispatched, and a more readable and semantic action history. It also means there's a lot less code to write.RTK also uses Immer inside to let you write "mutating" state update logic in reducers. That drastically simplifies the update logic.
So, while I'm always happy to see people building things with Redux, I'd really encourage to you rework the code generation and the patterns you're working with to use Redux Toolkit instead, and follow our recommended best practices. That will make things much easier for you and the people using what you've built.
Of course. I agree with all of this! At the same time, I can garuantee there are a huge number of companies that still use these deprecated patterns in their legacy code bases. This type of generation would be for them. My plan is to ultimately make ReduxPlate a new abstraction layer on top of Redux Toolkit. Additionally, I don't want to give too much of the secret sauce away, so that's why I've leaned on this deprecated generation pattern for the public facing example.
To be fair, it's probably worthwhile to note all these things in a disclaimer on the homepage.
Also, the action generation will be a main challenge and feature of ReduxPlate - can we merge actions? Add more than just the setting of a single property in state with them? This will depend a lot on customer's use cases and their own applications and code bases. All of these will eventually be possible with ReduxPlate.
To be honest, almost all the work on our TS types over the last couple years has been done by RTK maintainer Lenz Weber ( twitter.com/phry ). I've picked up enough TS to be able to follow some of what goes on, but there's still a lot of stuff in the codebase that's over my head :)
If you're interested in a discussion on the typings, I'd suggest talking to Lenz first.
Oops, I deleted my post, but it can not be undone it seems, bit quick on the trigger there.
Still much appreciate the quick reply! RTK seems a fine piece of work, looking forward to diving into it. Angular is my home turf, so also looking forward to seeing the toolkit implemented in NgRx!
Keep up the good work and stay frosty :)